Fix certificate View link (wrong /study/certificate URL), certificates in My Account, name on certificate; v2.10.10
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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-09 11:13:44 -04:00
1 parent 899e715067
commit 7301982bb1
8 files changed
+123 -30

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "siteforge", "name": "siteforge",
"version": "2.10.9", "version": "2.10.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "siteforge", "name": "siteforge",
"version": "2.10.9", "version": "2.10.10",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1135.0", "@aws-sdk/client-s3": "^3.1135.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "2.10.9", "version": "2.10.10",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -225,7 +225,7 @@ export function register(app) {
studySlug: c.studySlug, studySlug: c.studySlug,
studyTitle: c.studyTitle, studyTitle: c.studyTitle,
issuedAt: c.issuedAt, issuedAt: c.issuedAt,
url: buildAbsoluteUrl(baseUrl, `/study/certificate/${c.token}`), url: buildAbsoluteUrl(baseUrl, `/certificate/${c.token}`),
})) }))
res.json({ res.json({
+1 -1
View File
@@ -98,7 +98,7 @@ export function register(app) {
} }
state.studyCertificates.push(cert) state.studyCertificates.push(cert)
queueStudyCertificatesWrite() queueStudyCertificatesWrite()
const certUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/certificate/${cert.token}`) const certUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/certificate/${cert.token}`)
sendStudyCertificateEmail(user.username, user.displayName || user.username, cert.studyTitle, certUrl) sendStudyCertificateEmail(user.username, user.displayName || user.username, cert.studyTitle, certUrl)
.catch(err => console.error('[study-certificate] email exception:', err)) .catch(err => console.error('[study-certificate] email exception:', err))
} }
+53
View File
@@ -4488,6 +4488,59 @@
.account-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .account-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
.account-chip--muted { color: var(--brand-muted); border-color: rgba(255, 255, 255, 0.16); background: rgba(255, 255, 255, 0.04); }
.account-cert-study {
border-top: 1px solid rgba(201, 168, 76, 0.12);
padding: 0.35rem 0 0.6rem;
}
.account-cert-study:first-of-type { border-top: none; }
.account-cert-study > summary {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.6rem;
padding: 0.55rem 0;
list-style: none;
cursor: pointer;
}
.account-cert-study > summary::-webkit-details-marker { display: none; }
.account-cert-study > summary::after {
content: '▾';
color: var(--brand-gold);
margin-left: auto;
transition: transform 0.15s ease;
}
.account-cert-study:not([open]) > summary::after { transform: rotate(-90deg); }
.account-cert-title {
color: var(--brand-warm-white);
font-weight: 600;
}
.account-cert-study .account-cert-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
margin: 0.25rem 0 0.75rem;
}
.account-cert-study .account-cert-actions .btn-primary,
.account-cert-study .account-cert-actions .btn-secondary {
font-size: 0.82rem;
padding: 0.5rem 1.1rem;
}
.account-cert-study .study-cert-panel { margin-top: 0.5rem; }
.account-cert-study .study-cert-heading { display: none; }
.account-cert-study .study-cert-canvas { width: 100%; height: auto; max-width: 100%; }
/* ── Study hub (guest landing) ── */ /* ── Study hub (guest landing) ── */
.study-hub-count { .study-hub-count {
margin: 1.25rem 0 0; margin: 1.25rem 0 0;
+8 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import type { ReactElement } from 'react' import type { ReactElement } from 'react'
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom' import { Link, NavLink, Navigate, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
import AdminPage from './AdminPage' import AdminPage from './AdminPage'
import QASection from './components/QASection' import QASection from './components/QASection'
import ContactForm from './components/ContactForm' import ContactForm from './components/ContactForm'
@@ -2956,6 +2956,12 @@ function LegalPage({ title, body }: { title: string; body: string[] }) {
) )
} }
// Certificate links emailed before v2.10.10 pointed at /study/certificate/:token.
function LegacyCertificateRedirect() {
const { token } = useParams<{ token: string }>()
return <Navigate to={`/certificate/${encodeURIComponent(token ?? '')}`} replace />
}
function PublicCertificatePage() { function PublicCertificatePage() {
const { token } = useParams<{ token: string }>() const { token } = useParams<{ token: string }>()
const [cert, setCert] = useState<{ studyTitle: string; displayName: string; issuedAt: string } | null>(null) const [cert, setCert] = useState<{ studyTitle: string; displayName: string; issuedAt: string } | null>(null)
@@ -3536,6 +3542,7 @@ export default function App() {
<Route path="/study/verify-email" element={<StudyRouteFrame content={content} child={<StudyVerifyEmailPage />} />} /> <Route path="/study/verify-email" element={<StudyRouteFrame content={content} child={<StudyVerifyEmailPage />} />} />
<Route path="/study/account" element={<StudyRouteFrame content={content} child={<StudyAccountPage />} />} /> <Route path="/study/account" element={<StudyRouteFrame content={content} child={<StudyAccountPage />} />} />
<Route path="/study/discussion" element={<StudyRouteFrame content={content} child={<StudyDiscussionHubPage content={content} />} />} /> <Route path="/study/discussion" element={<StudyRouteFrame content={content} child={<StudyDiscussionHubPage content={content} />} />} />
<Route path="/study/certificate/:token" element={<LegacyCertificateRedirect />} />
<Route path="/study/:studySlug" element={<StudyRouteFrame content={content} child={<ColossiansStudyIndexPage content={content} />} />} /> <Route path="/study/:studySlug" element={<StudyRouteFrame content={content} child={<ColossiansStudyIndexPage content={content} />} />} />
<Route path="/study/:studySlug/community" element={<StudyRouteFrame content={content} child={<StudyCommunityPage content={content} />} />} /> <Route path="/study/:studySlug/community" element={<StudyRouteFrame content={content} child={<StudyCommunityPage content={content} />} />} />
<Route path="/study/:studySlug/notes" element={<StudyRouteFrame content={content} child={<ColossiansStudyNotesPage content={content} />} />} /> <Route path="/study/:studySlug/notes" element={<StudyRouteFrame content={content} child={<ColossiansStudyNotesPage content={content} />} />} />
+23 -14
View File
@@ -11,6 +11,7 @@ interface CertificateStatus {
interface Props { interface Props {
studySlug: string studySlug: string
onIssued?: () => void
} }
function formatDate(iso: string | null) { function formatDate(iso: string | null) {
@@ -115,7 +116,7 @@ function drawCertificate(
ctx.fillText('versebyversewithnate.us', W / 2, 490) ctx.fillText('versebyversewithnate.us', W / 2, 490)
} }
export function StudyCertificate({ studySlug }: Props) { export function StudyCertificate({ studySlug, onIssued }: Props) {
const [status, setStatus] = useState<CertificateStatus | null>(null) const [status, setStatus] = useState<CertificateStatus | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [issuing, setIssuing] = useState(false) const [issuing, setIssuing] = useState(false)
@@ -124,20 +125,27 @@ export function StudyCertificate({ studySlug }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => { useEffect(() => {
fetch(`/api/study-certificate/${studySlug}`) let cancelled = false
.then(r => r.json()) async function load() {
.then(data => { 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) setStatus(data)
// Try to get display name from account // The name printed on the certificate comes from the signed-in account
return fetch('/api/study-account') const auth = await fetch('/api/study-auth/status').then(r => (r.ok ? r.json() : null)).catch(() => null)
}) if (cancelled) return
.then(r => r.json()) if (auth?.displayName) setDisplayName(auth.displayName)
.then(data => { else if (auth?.username) setDisplayName(auth.username)
if (data?.displayName) setDisplayName(data.displayName) } catch {
else if (data?.username) setDisplayName(data.username) // leave status null; the panel renders nothing
}) } finally {
.catch(() => {}) if (!cancelled) setLoading(false)
.finally(() => setLoading(false)) }
}
void load()
return () => { cancelled = true }
}, [studySlug]) }, [studySlug])
useEffect(() => { useEffect(() => {
@@ -159,6 +167,7 @@ export function StudyCertificate({ studySlug }: Props) {
const data = await res.json() const data = await res.json()
if (!res.ok) { setError(data.message ?? 'Failed to issue certificate.'); return } if (!res.ok) { setError(data.message ?? 'Failed to issue certificate.'); return }
setStatus(prev => prev ? { ...prev, token: data.token, issuedAt: data.issuedAt } : prev) setStatus(prev => prev ? { ...prev, token: data.token, issuedAt: data.issuedAt } : prev)
onIssued?.()
} catch { } catch {
setError('Network error. Please try again.') setError('Network error. Please try again.')
} finally { } finally {
+34 -10
View File
@@ -2872,6 +2872,7 @@ export function StudyAccountPage() {
const [pwMessage, setPwMessage] = useState('') const [pwMessage, setPwMessage] = useState('')
const [pwSuccess, setPwSuccess] = useState(false) const [pwSuccess, setPwSuccess] = useState(false)
const [certOpen, setCertOpen] = useState<Record<string, boolean>>({})
const [signOutBusy, setSignOutBusy] = useState(false) const [signOutBusy, setSignOutBusy] = useState(false)
const [signOutAllBusy, setSignOutAllBusy] = useState(false) const [signOutAllBusy, setSignOutAllBusy] = useState(false)
const [signOutAllMessage, setSignOutAllMessage] = useState('') const [signOutAllMessage, setSignOutAllMessage] = useState('')
@@ -3298,18 +3299,41 @@ export function StudyAccountPage() {
})} })}
</section> </section>
{overview.certificates.length > 0 && ( {enrolledProgress.length > 0 && (
<section className="account-card" aria-label="Certificates"> <section className="account-card" aria-label="Certificates">
<div className="account-card-head"><h2>Certificates</h2></div> <div className="account-card-head"><h2>Certificates</h2></div>
{overview.certificates.map(cert => ( <p className="account-muted">Finish every lesson in a study to earn a certificate of completion you can view, download and share.</p>
<div className="account-cert" key={cert.studySlug}> {enrolledProgress.map(item => {
<span>🏅 {cert.studyTitle} <small>— {new Date(cert.issuedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</small></span> const cert = overview.certificates.find(c => c.studySlug === item.slug)
<div className="account-cert-actions"> const ready = !cert && item.completedLessons >= item.totalLessons
<a href={cert.url} target="_blank" rel="noopener noreferrer" className="btn-secondary">View</a> return (
<button type="button" className="btn-secondary" onClick={() => { navigator.clipboard?.writeText(cert.url).catch(() => {}) }}>Copy link</button> <details
</div> className="account-cert-study"
</div> key={item.slug}
))} open={certOpen[item.slug] ?? ready}
onToggle={e => {
const isOpen = (e.currentTarget as HTMLDetailsElement).open
setCertOpen(prev => (prev[item.slug] === isOpen ? prev : { ...prev, [item.slug]: isOpen }))
}}
>
<summary>
<span className="account-cert-title">{cert ? '🏅' : '🎓'} {item.title}</span>
{cert
? <span className="account-chip account-chip--ok">Earned {new Date(cert.issuedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</span>
: ready
? <span className="account-chip account-chip--warn">Ready to claim</span>
: <span className="account-chip account-chip--muted">{item.completedLessons} of {item.totalLessons} lessons</span>}
</summary>
{cert && (
<div className="account-cert-actions">
<Link to={new URL(cert.url).pathname} target="_blank" rel="noopener noreferrer" className="btn-primary">View certificate</Link>
<button type="button" className="btn-secondary" onClick={() => { navigator.clipboard?.writeText(cert.url).catch(() => {}) }}>Copy link</button>
</div>
)}
<StudyCertificate studySlug={item.slug} onIssued={() => { void refreshOverview() }} />
</details>
)
})}
</section> </section>
)} )}