Publish Container / build (push) Successful in 15m29s
- 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>
260 lines
7.5 KiB
TypeScript
260 lines
7.5 KiB
TypeScript
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>
|
||
)
|
||
}
|