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
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:
1 parent
899e715067
commit
7301982bb1
8 files changed
+120
-27
No files matched your search
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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} />} />} />
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+31
-7
@@ -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)
|
||||||
|
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">
|
<div className="account-cert-actions">
|
||||||
<a href={cert.url} target="_blank" rel="noopener noreferrer" className="btn-secondary">View</a>
|
<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>
|
<button type="button" className="btn-secondary" onClick={() => { navigator.clipboard?.writeText(cert.url).catch(() => {}) }}>Copy link</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
))}
|
<StudyCertificate studySlug={item.slug} onIssued={() => { void refreshOverview() }} />
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user