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:
1 parent
9ad24df626
commit
c4645e3475
24 files changed
+2991
-76
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user