Files
Siteforge/src/components/StudyCertificate.tsx
T
nmemmertandClaude Sonnet 5.5 7301982bb1
Publish Container / build (push) Successful in 15m29s
Fix certificate View link (wrong /study/certificate URL), certificates in My Account, name on certificate; v2.10.10
- Certificate URLs in the account overview and the issuance email pointed at /study/certificate/:token,
  which the router treated as a lesson page; now /certificate/:token (old links redirect)
- My Account Certificates card lists every enrolled study: progress, Generate, View, Copy link, Download PNG, Share
- Certificate canvas used a non-existent /api/study-account route and always printed 'Student'; now reads the account name
- Panel no longer collapses after generating

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 11:13:44 -04:00

260 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
onIssued?: () => void
}
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, onIssued }: 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(() => {
let cancelled = false
async function load() {
try {
const res = await fetch(`/api/study-certificate/${studySlug}`)
if (!res.ok) throw new Error('unavailable')
const data = await res.json() as CertificateStatus
if (cancelled) return
setStatus(data)
// The name printed on the certificate comes from the signed-in account
const auth = await fetch('/api/study-auth/status').then(r => (r.ok ? r.json() : null)).catch(() => null)
if (cancelled) return
if (auth?.displayName) setDisplayName(auth.displayName)
else if (auth?.username) setDisplayName(auth.username)
} catch {
// leave status null; the panel renders nothing
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [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)
onIssued?.()
} 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>
)
}