Add cross-site improvements across three phases

Phase 1 — Quick wins:
- Image lazy-loading on series/resource cards
- Newsletter signup added to Episodes page (before highlights)
- Per-route meta tags via usePageMeta hook (title, og:title, og:description)
- Breadcrumbs on study index, section, and notes pages
- SVG completion checkmark badges on study section list
- Analytics time-range filter (7d / 30d / 90d) in admin panel

Phase 2 — Medium features:
- Related episodes on archived series detail pages
- Resource library two-tier filter (type + tag chips)
- Global search (Fuse.js) moved below sticky header as full-width bar
- Q&A anonymous upvoting with localStorage dedup + admin pin/unpin
- Study enrollment funnel tracking (firstVisitAt, firstCompletionAt) with funnel chart in analytics

Phase 3 — Larger features:
- Study section comments (auto-approve for enrolled users, admin moderation panel)
- Study completion certificate (canvas render, PNG download, shareable public URL)
- Episode script full-text search (mammoth docx extraction, server-side search, admin upload UI)
- Reflection questions renamed from Discussion Questions; quiz answers can be shared to section discussion
- Public certificate route at /certificate/:token with og meta tags
- Comment moderation panel added to admin under Manage > Study Comments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-06-09 09:10:29 -04:00
1 parent 9ad24df626
commit c4645e3475
24 files changed
+2991 -76

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
import { useEffect, useRef, useState } from 'react'
interface Comment {
id: string
displayName: string
text: string
createdAt: string
}
interface Props {
studySlug: string
sectionId: string
isEnrolled: boolean
}
function formatDate(iso: string) {
try {
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
} catch {
return ''
}
}
export function StudySectionComments({ studySlug, sectionId, isEnrolled }: Props) {
const [comments, setComments] = useState<Comment[]>([])
const [loading, setLoading] = useState(true)
const [text, setText] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const textRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
setLoading(true)
fetch(`/api/study-comments/${studySlug}/${sectionId}`)
.then(r => r.json())
.then(data => setComments(Array.isArray(data.comments) ? data.comments : []))
.catch(() => setComments([]))
.finally(() => setLoading(false))
}, [studySlug, sectionId])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!text.trim()) return
setSubmitting(true)
setError(null)
setSuccess(false)
try {
const res = await fetch(`/api/study-comments/${studySlug}/${sectionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text.trim() }),
})
const data = await res.json()
if (!res.ok) {
setError(data.message ?? 'Failed to post comment.')
return
}
setComments(prev => [...prev, data.comment])
setText('')
setSuccess(true)
setTimeout(() => setSuccess(false), 3000)
} catch {
setError('Network error. Please try again.')
} finally {
setSubmitting(false)
}
}
async function handleDelete(commentId: string) {
setDeletingId(commentId)
try {
await fetch(`/api/study-comments/${commentId}`, { method: 'DELETE' })
setComments(prev => prev.filter(c => c.id !== commentId))
} catch {
// ignore
} finally {
setDeletingId(null)
}
}
return (
<section className="study-comments">
<h3 className="study-comments-heading">Share Your Thoughts</h3>
{loading ? (
<p className="study-comments-loading">Loading comments…</p>
) : comments.length === 0 ? (
<p className="study-comments-empty">No comments yet. Be the first to share a thought!</p>
) : (
<ul className="study-comments-list">
{comments.map(comment => (
<li key={comment.id} className="study-comment">
<div className="study-comment-header">
<span className="study-comment-author">{comment.displayName}</span>
<span className="study-comment-date">{formatDate(comment.createdAt)}</span>
</div>
<p className="study-comment-text">{comment.text}</p>
<button
type="button"
className="study-comment-delete"
onClick={() => handleDelete(comment.id)}
disabled={deletingId === comment.id}
aria-label="Delete comment"
>
{deletingId === comment.id ? 'Deleting…' : 'Delete'}
</button>
</li>
))}
</ul>
)}
{isEnrolled ? (
<form className="study-comment-form" onSubmit={handleSubmit}>
<label htmlFor="study-comment-input" className="study-comment-form-label">
Leave a comment
</label>
<textarea
id="study-comment-input"
ref={textRef}
className="study-comment-textarea"
value={text}
onChange={e => setText(e.target.value)}
placeholder="Share a thought, question, or encouragement…"
rows={3}
maxLength={2000}
disabled={submitting}
/>
<div className="study-comment-form-footer">
<span className="study-comment-char-count">{text.length}/2000</span>
{error && <span className="study-comment-error">{error}</span>}
{success && <span className="study-comment-success">Comment posted!</span>}
<button
type="submit"
className="study-comment-submit"
disabled={submitting || !text.trim()}
>
{submitting ? 'Posting…' : 'Post Comment'}
</button>
</div>
</form>
) : (
<p className="study-comments-enroll-note">
<a href="/study">Enroll in this study</a> to join the discussion.
</p>
)}
</section>
)
}