tractatus/public/js/components/footer.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

380 lines
18 KiB
JavaScript

/**
* Footer Component - i18n-enabled
* Shared footer for all Tractatus pages with language persistence
*/
(function() {
'use strict';
class TractatusFooter {
constructor() {
this.init();
}
init() {
console.log('[Footer] Initializing...');
// Listen for i18n initialization event (fired by i18n-simple.js)
if (window.I18n && window.I18n.translations && window.I18n.translations.footer) {
// i18n already loaded
console.log('[Footer] i18n already loaded, rendering immediately');
this.render();
this.attachEventListeners();
} else {
// Wait for i18nInitialized event
console.log('[Footer] Waiting for i18nInitialized event...');
window.addEventListener('i18nInitialized', () => {
console.log('[Footer] i18n initialized event received');
// Double-check translations loaded
if (window.I18n && window.I18n.translations && window.I18n.translations.footer) {
console.log('[Footer] Footer translations confirmed, rendering');
this.render();
this.attachEventListeners();
} else {
console.error('[Footer] Event fired but no footer translations:', window.I18n?.translations);
// Render anyway
this.render();
this.attachEventListeners();
}
}, { once: true });
}
}
render() {
const currentYear = new Date().getFullYear();
// Create footer HTML with data-i18n attributes
const footerHTML = `
<footer class="bg-gray-900 text-gray-300 mt-16" role="contentinfo">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Main Footer Content -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8">
<!-- About -->
<div>
<h3 class="text-white font-semibold mb-4" data-i18n="footer.about_heading">Tractatus Framework</h3>
<p class="text-sm text-gray-400" data-i18n="footer.about_text">
Architectural constraints for AI safety that preserve human agency through structural, not aspirational, enforcement.
</p>
</div>
<!-- Documentation -->
<div>
<h3 class="text-white font-semibold mb-4" data-i18n="footer.documentation_heading">Documentation</h3>
<ul class="space-y-2 text-sm">
<li><a href="/docs.html" class="hover:text-white transition" data-i18n="footer.documentation_links.framework_docs">Framework Docs</a></li>
<li><a href="/about.html" class="hover:text-white transition" data-i18n="footer.documentation_links.about">About</a></li>
<li><a href="/about/values.html" class="hover:text-white transition" data-i18n="footer.documentation_links.core_values">Core Values</a></li>
<li><a href="/demos/27027-demo.html" class="hover:text-white transition" data-i18n="footer.documentation_links.interactive_demo">Interactive Demo</a></li>
</ul>
</div>
<!-- Support -->
<div>
<h3 class="text-white font-semibold mb-4" data-i18n="footer.support_heading">Support</h3>
<ul class="space-y-2 text-sm">
<li><a href="/koha.html" class="hover:text-white transition" data-i18n="footer.support_links.koha">Support (Koha)</a></li>
<li><a href="/koha/transparency.html" class="hover:text-white transition" data-i18n="footer.support_links.transparency">Transparency</a></li>
<li><a href="/media-inquiry.html" class="hover:text-white transition" data-i18n="footer.support_links.media_inquiries">Media Inquiries</a></li>
<li><a href="/case-submission.html" class="hover:text-white transition" data-i18n="footer.support_links.submit_case">Submit Case Study</a></li>
</ul>
</div>
<!-- Legal & Contact -->
<div>
<h3 class="text-white font-semibold mb-4" data-i18n="footer.legal_heading">Legal</h3>
<ul class="space-y-2 text-sm">
<li><a href="/privacy.html" class="hover:text-white transition" data-i18n="footer.legal_links.privacy">Privacy Policy</a></li>
<li><a href="/gdpr.html" class="hover:text-white transition" data-i18n="footer.legal_links.gdpr">GDPR Compliance</a></li>
<li><button id="open-contact-modal" class="hover:text-white transition cursor-pointer text-left" data-i18n="footer.legal_links.contact">Contact Us</button></li>
<li><button data-newsletter-trigger class="hover:text-white transition cursor-pointer text-left" data-i18n="footer.legal_links.newsletter">Newsletter</button></li>
<li><a href="https://github.com/AgenticGovernance/tractatus-framework" class="hover:text-white transition" target="_blank" rel="noopener">GitHub</a></li>
</ul>
</div>
</div>
<!-- Divider -->
<div class="border-t border-gray-800 pt-8">
<!-- Te Tiriti Acknowledgement -->
<div class="mb-6">
<p class="text-sm text-gray-400">
<strong class="text-gray-300" data-i18n="footer.te_tiriti_label">Te Tiriti o Waitangi:</strong>
<span data-i18n="footer.te_tiriti_text">We acknowledge Te Tiriti o Waitangi and our commitment to partnership, protection, and participation. This project respects Māori data sovereignty (rangatiratanga) and collective guardianship (kaitiakitanga).</span>
</p>
</div>
<!-- Bottom Row -->
<div class="flex flex-col md:flex-row justify-between items-center gap-4 text-sm">
<p class="text-gray-400">
© ${currentYear} <span data-i18n="footer.copyright">John G Stroh. Licensed under</span> <a href="https://www.apache.org/licenses/LICENSE-2.0" class="text-blue-400 hover:text-blue-300 transition" target="_blank" rel="noopener"><span data-i18n="footer.license">Apache 2.0</span></a>.
</p>
<p class="text-gray-400" data-i18n="footer.location">
Made in Aotearoa New Zealand 🇳🇿
</p>
</div>
</div>
</div>
</footer>
<!-- Contact Modal -->
<div id="contact-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<h2 data-i18n="contact_modal.heading" class="text-2xl font-bold text-gray-900">Contact Us</h2>
<button id="close-contact-modal" class="text-gray-400 hover:text-gray-600 text-2xl leading-none">&times;</button>
</div>
<p data-i18n="contact_modal.description" class="text-gray-600 mb-6">Have a question or want to get in touch? Fill out the form below and we'll respond within 24 hours.</p>
<form id="contact-form" class="space-y-4">
<!-- Type -->
<div>
<label for="contact-type" data-i18n="contact_modal.type_label" class="block text-sm font-medium text-gray-700 mb-1">Inquiry Type</label>
<select id="contact-type" name="type" required class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="general" data-i18n="contact_modal.type_options.general">General Inquiry</option>
<option value="partnership" data-i18n="contact_modal.type_options.partnership">Partnership</option>
<option value="technical" data-i18n="contact_modal.type_options.technical">Technical Support</option>
<option value="feedback" data-i18n="contact_modal.type_options.feedback">Feedback</option>
</select>
</div>
<!-- Name -->
<div>
<label for="contact-name" data-i18n="contact_modal.name_label" class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input type="text" id="contact-name" name="name" required maxlength="100" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<!-- Email -->
<div>
<label for="contact-email" data-i18n="contact_modal.email_label" class="block text-sm font-medium text-gray-700 mb-1">Email *</label>
<input type="email" id="contact-email" name="email" required maxlength="254" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<!-- Organization (optional) -->
<div>
<label for="contact-organization" data-i18n="contact_modal.organization_label" class="block text-sm font-medium text-gray-700 mb-1">Organization (optional)</label>
<input type="text" id="contact-organization" name="organization" maxlength="200" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<!-- Subject (optional) -->
<div>
<label for="contact-subject" data-i18n="contact_modal.subject_label" class="block text-sm font-medium text-gray-700 mb-1">Subject (optional)</label>
<input type="text" id="contact-subject" name="subject" maxlength="200" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<!-- Message -->
<div>
<label for="contact-message" data-i18n="contact_modal.message_label" class="block text-sm font-medium text-gray-700 mb-1">Message *</label>
<textarea id="contact-message" name="message" required maxlength="5000" rows="5" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"></textarea>
<p data-i18n="contact_modal.message_help" class="text-xs text-gray-500 mt-1">Maximum 5000 characters</p>
</div>
<!-- Success Message -->
<div id="contact-success" class="hidden bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded">
<span data-i18n="contact_modal.success_message">Thank you for contacting us! We will respond within 24 hours.</span>
</div>
<!-- Error Message -->
<div id="contact-error" class="hidden bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded">
<span id="contact-error-message"></span>
</div>
<!-- Actions -->
<div class="flex items-center justify-between pt-4">
<p class="text-sm text-gray-500"><span data-i18n="contact_modal.email_link_text">Or email us at</span> <a href="mailto:hello@agenticgovernance.digital" class="text-blue-600 hover:underline">hello@agenticgovernance.digital</a></p>
<div class="flex gap-2">
<button type="button" id="cancel-contact" class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"data-i18n="contact_modal.cancel_button">Cancel</button>
<button type="submit" id="contact-submit" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"data-i18n="contact_modal.submit_button">Send Message</button>
</div>
</div>
</form>
</div>
</div>
</div>
`;
// Insert footer at end of body
const existingFooter = document.querySelector('footer[role="contentinfo"]');
if (existingFooter) {
existingFooter.outerHTML = footerHTML;
} else if (document.body) {
document.body.insertAdjacentHTML('beforeend', footerHTML);
} else {
// If body not ready, wait for DOM
document.addEventListener('DOMContentLoaded', () => {
document.body.insertAdjacentHTML('beforeend', footerHTML);
this.applyFooterTranslations();
});
return; // Exit early if DOM not ready
}
// Apply translations after DOM update
this.applyFooterTranslations();
}
applyFooterTranslations() {
// Use double requestAnimationFrame to ensure DOM is fully painted
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (window.I18n && window.I18n.applyTranslations) {
console.log('[Footer] Applying translations...');
console.log('[Footer] Footer exists:', !!document.querySelector('footer[role="contentinfo"]'));
console.log('[Footer] Elements with data-i18n:', document.querySelectorAll('footer[role="contentinfo"] [data-i18n]').length);
window.I18n.applyTranslations();
console.log('[Footer] Translations applied');
// Verify a sample translation
const aboutHeading = document.querySelector('footer [data-i18n="footer.about_heading"]');
if (aboutHeading) {
console.log('[Footer] about_heading element text:', aboutHeading.innerHTML);
}
} else {
console.warn('[Footer] I18n not available for translation');
}
});
});
}
attachEventListeners() {
// Listen for language changes and re-render footer
window.addEventListener('languageChanged', (event) => {
console.log('[Footer] Language changed to:', event.detail.language);
this.render();
});
// Contact modal functionality
this.setupContactModal();
}
setupContactModal() {
const modal = document.getElementById('contact-modal');
const openBtn = document.getElementById('open-contact-modal');
const closeBtn = document.getElementById('close-contact-modal');
const cancelBtn = document.getElementById('cancel-contact');
const form = document.getElementById('contact-form');
const successMsg = document.getElementById('contact-success');
const errorMsg = document.getElementById('contact-error');
const errorText = document.getElementById('contact-error-message');
const submitBtn = document.getElementById('contact-submit');
if (!modal || !openBtn || !form) {
console.warn('[Footer] Contact modal elements not found');
return;
}
const openModal = () => {
modal.classList.remove('hidden');
// Re-apply translations to modal when opening
if (window.I18n && window.I18n.applyTranslations) {
window.I18n.applyTranslations();
}
document.getElementById('contact-name')?.focus();
};
const closeModal = () => {
modal.classList.add('hidden');
form.reset();
successMsg.classList.add('hidden');
errorMsg.classList.add('hidden');
};
// Event listeners
openBtn.addEventListener('click', (e) => {
e.preventDefault();
openModal();
});
closeBtn?.addEventListener('click', closeModal);
cancelBtn?.addEventListener('click', closeModal);
// Close on backdrop click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeModal();
}
});
// Form submission
form.addEventListener('submit', async (e) => {
e.preventDefault();
// Reset messages
successMsg.classList.add('hidden');
errorMsg.classList.add('hidden');
const formData = {
type: document.getElementById('contact-type').value,
name: document.getElementById('contact-name').value,
email: document.getElementById('contact-email').value,
organization: document.getElementById('contact-organization').value || null,
subject: document.getElementById('contact-subject').value || null,
message: document.getElementById('contact-message').value
};
// Disable submit button
submitBtn.disabled = true;
// Use translation if available, fallback to English
const sendingText = window.I18n?.translations?.contact_modal?.submitting || 'Sending...';
submitBtn.textContent = sendingText;
try {
// Get CSRF token from cookie
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf-token='))
?.split('=')[1];
const response = await fetch('/api/contact/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken || ''
},
body: JSON.stringify(formData)
});
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.message || data.error || 'Failed to send message. Please try again.';
errorMsg.classList.remove('hidden');
}
} catch (error) {
console.error('Contact form 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;
// Use translation if available, fallback to English
const submitText = window.I18n?.translations?.contact_modal?.submit_button || 'Send Message';
submitBtn.textContent = submitText;
}
});
}
}
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => new TractatusFooter());
} else {
new TractatusFooter();
}
})();