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

+53
View File
@@ -4488,6 +4488,59 @@
.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-count {
margin: 1.25rem 0 0;
+8 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useMemo } 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 QASection from './components/QASection'
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() {
const { token } = useParams<{ token: string }>()
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/account" element={<StudyRouteFrame content={content} child={<StudyAccountPage />} />} />
<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/community" element={<StudyRouteFrame content={content} child={<StudyCommunityPage 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 {
studySlug: string
onIssued?: () => void
}
function formatDate(iso: string | null) {
@@ -115,7 +116,7 @@ function drawCertificate(
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 [loading, setLoading] = useState(true)
const [issuing, setIssuing] = useState(false)
@@ -124,20 +125,27 @@ export function StudyCertificate({ studySlug }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
fetch(`/api/study-certificate/${studySlug}`)
.then(r => r.json())
.then(data => {
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)
// 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))
// 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(() => {
@@ -159,6 +167,7 @@ export function StudyCertificate({ studySlug }: Props) {
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 {
+34 -10
View File
@@ -2872,6 +2872,7 @@ export function StudyAccountPage() {
const [pwMessage, setPwMessage] = useState('')
const [pwSuccess, setPwSuccess] = useState(false)
const [certOpen, setCertOpen] = useState<Record<string, boolean>>({})
const [signOutBusy, setSignOutBusy] = useState(false)
const [signOutAllBusy, setSignOutAllBusy] = useState(false)
const [signOutAllMessage, setSignOutAllMessage] = useState('')
@@ -3298,18 +3299,41 @@ export function StudyAccountPage() {
})}
</section>
{overview.certificates.length > 0 && (
{enrolledProgress.length > 0 && (
<section className="account-card" aria-label="Certificates">
<div className="account-card-head"><h2>Certificates</h2></div>
{overview.certificates.map(cert => (
<div className="account-cert" key={cert.studySlug}>
<span>🏅 {cert.studyTitle} <small>— {new Date(cert.issuedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</small></span>
<div className="account-cert-actions">
<a href={cert.url} target="_blank" rel="noopener noreferrer" className="btn-secondary">View</a>
<button type="button" className="btn-secondary" onClick={() => { navigator.clipboard?.writeText(cert.url).catch(() => {}) }}>Copy link</button>
</div>
</div>
))}
<p className="account-muted">Finish every lesson in a study to earn a certificate of completion you can view, download and share.</p>
{enrolledProgress.map(item => {
const cert = overview.certificates.find(c => c.studySlug === item.slug)
const ready = !cert && item.completedLessons >= item.totalLessons
return (
<details
className="account-cert-study"
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>
)}