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

+250
View File
@@ -0,0 +1,250 @@
import { useEffect, useRef, useState } from 'react'
interface CertificateStatus {
eligible: boolean
studyTitle: string
completedCount: number
totalCount: number
token: string | null
issuedAt: string | null
}
interface Props {
studySlug: string
}
function formatDate(iso: string | null) {
if (!iso) return ''
try {
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
} catch {
return ''
}
}
function drawCertificate(
canvas: HTMLCanvasElement,
studyTitle: string,
displayName: string,
issuedAt: string,
) {
const W = 900
const H = 630
canvas.width = W
canvas.height = H
const ctx = canvas.getContext('2d')!
// Background
ctx.fillStyle = '#1a1710'
ctx.fillRect(0, 0, W, H)
// Gold border (outer)
ctx.strokeStyle = '#c8952a'
ctx.lineWidth = 8
ctx.strokeRect(16, 16, W - 32, H - 32)
// Gold border (inner)
ctx.strokeStyle = '#e0b840'
ctx.lineWidth = 2
ctx.strokeRect(28, 28, W - 56, H - 56)
// Header label
ctx.fillStyle = '#8a7f5a'
ctx.font = '700 15px Georgia, serif'
ctx.textAlign = 'center'
ctx.fillText('VERSE BY VERSE WITH NATE', W / 2, 80)
// "Certificate of Completion"
ctx.fillStyle = '#e0b840'
ctx.font = 'italic 700 38px Georgia, serif'
ctx.fillText('Certificate of Completion', W / 2, 145)
// Decorative line
ctx.strokeStyle = '#c8952a'
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(W / 2 - 200, 162)
ctx.lineTo(W / 2 + 200, 162)
ctx.stroke()
// "This certifies that"
ctx.fillStyle = '#b8a87a'
ctx.font = '16px Georgia, serif'
ctx.fillText('This certifies that', W / 2, 210)
// Name
ctx.fillStyle = '#f5f0e8'
ctx.font = 'italic 700 44px Georgia, serif'
ctx.fillText(displayName, W / 2, 272)
// Underline the name
const nameWidth = ctx.measureText(displayName).width
ctx.strokeStyle = '#e0b840'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(W / 2 - nameWidth / 2, 282)
ctx.lineTo(W / 2 + nameWidth / 2, 282)
ctx.stroke()
// "has successfully completed"
ctx.fillStyle = '#b8a87a'
ctx.font = '16px Georgia, serif'
ctx.fillText('has successfully completed', W / 2, 322)
// Study title
ctx.fillStyle = '#f5f0e8'
ctx.font = '700 28px Georgia, serif'
ctx.fillText(studyTitle, W / 2, 372)
// Date line
ctx.fillStyle = '#8a7f5a'
ctx.font = '14px Georgia, serif'
ctx.fillText(`Awarded on ${formatDate(issuedAt)}`, W / 2, 430)
// Decorative flourish bottom
ctx.strokeStyle = '#c8952a'
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(W / 2 - 180, 460)
ctx.lineTo(W / 2 + 180, 460)
ctx.stroke()
// Footer
ctx.fillStyle = '#5a5040'
ctx.font = '12px Georgia, serif'
ctx.fillText('versebyversewithnate.us', W / 2, 490)
}
export function StudyCertificate({ studySlug }: Props) {
const [status, setStatus] = useState<CertificateStatus | null>(null)
const [loading, setLoading] = useState(true)
const [issuing, setIssuing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [displayName, setDisplayName] = useState('')
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
fetch(`/api/study-certificate/${studySlug}`)
.then(r => r.json())
.then(data => {
setStatus(data)
// Try to get display name from account
return fetch('/api/study-account')
})
.then(r => r.json())
.then(data => {
if (data?.displayName) setDisplayName(data.displayName)
else if (data?.username) setDisplayName(data.username)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [studySlug])
useEffect(() => {
if (status?.token && canvasRef.current) {
drawCertificate(
canvasRef.current,
status.studyTitle,
displayName || 'Student',
status.issuedAt ?? new Date().toISOString(),
)
}
}, [status, displayName])
async function handleIssueCertificate() {
setIssuing(true)
setError(null)
try {
const res = await fetch(`/api/study-certificate/${studySlug}`, { method: 'POST' })
const data = await res.json()
if (!res.ok) { setError(data.message ?? 'Failed to issue certificate.'); return }
setStatus(prev => prev ? { ...prev, token: data.token, issuedAt: data.issuedAt } : prev)
} catch {
setError('Network error. Please try again.')
} finally {
setIssuing(false)
}
}
function handleDownload() {
if (!canvasRef.current) return
const link = document.createElement('a')
link.download = `certificate-${studySlug}.png`
link.href = canvasRef.current.toDataURL('image/png')
link.click()
}
function handleShare() {
if (!status?.token) return
const url = `${window.location.origin}/certificate/${status.token}`
if (navigator.share) {
navigator.share({ title: `Certificate – ${status.studyTitle}`, url }).catch(() => {})
} else {
navigator.clipboard.writeText(url).then(() => alert('Certificate link copied!')).catch(() => {})
}
}
if (loading) return <p className="study-cert-loading">Checking completion status…</p>
if (!status) return null
const progressPct = status.totalCount > 0
? Math.round((status.completedCount / status.totalCount) * 100)
: 0
return (
<div className="study-cert-panel">
<h3 className="study-cert-heading">Certificate of Completion</h3>
{!status.eligible && (
<div className="study-cert-progress">
<p className="study-cert-progress-label">
{status.completedCount} of {status.totalCount} sections completed ({progressPct}%)
</p>
<div className="study-cert-progress-bar-wrap">
<div
className="study-cert-progress-bar"
style={{ width: `${progressPct}%` }}
/>
</div>
<p className="study-cert-note">
Complete all sections to earn your certificate.
</p>
</div>
)}
{status.eligible && !status.token && (
<div className="study-cert-eligible">
<p className="study-cert-congrats">
🎉 Congratulations! You've completed <strong>{status.studyTitle}</strong>.
</p>
{error && <p className="study-cert-error">{error}</p>}
<button
type="button"
className="study-cert-btn"
onClick={handleIssueCertificate}
disabled={issuing}
>
{issuing ? 'Generating…' : 'Generate My Certificate'}
</button>
</div>
)}
{status.token && (
<div className="study-cert-issued">
<p className="study-cert-issued-label">
Issued on {formatDate(status.issuedAt)}
</p>
<canvas ref={canvasRef} className="study-cert-canvas" aria-label="Certificate of completion" />
<div className="study-cert-actions">
<button type="button" className="study-cert-btn" onClick={handleDownload}>
Download PNG
</button>
<button type="button" className="study-cert-btn study-cert-btn--secondary" onClick={handleShare}>
Share Link
</button>
</div>
</div>
)}
</div>
)
}