diff --git a/package-lock.json b/package-lock.json index 3a08d42..90aee52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siteforge", - "version": "2.10.9", + "version": "2.10.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siteforge", - "version": "2.10.9", + "version": "2.10.10", "dependencies": { "@aws-sdk/client-s3": "^3.1135.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 36f3582..6bcbe9f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.10.9", + "version": "2.10.10", "type": "module", "scripts": { "dev": "vite", diff --git a/server/routes/study-account.js b/server/routes/study-account.js index 95d4bf0..e592f3a 100644 --- a/server/routes/study-account.js +++ b/server/routes/study-account.js @@ -225,7 +225,7 @@ export function register(app) { studySlug: c.studySlug, studyTitle: c.studyTitle, issuedAt: c.issuedAt, - url: buildAbsoluteUrl(baseUrl, `/study/certificate/${c.token}`), + url: buildAbsoluteUrl(baseUrl, `/certificate/${c.token}`), })) res.json({ diff --git a/server/routes/study-certificate.js b/server/routes/study-certificate.js index 0f69198..ba5cbbd 100644 --- a/server/routes/study-certificate.js +++ b/server/routes/study-certificate.js @@ -98,7 +98,7 @@ export function register(app) { } state.studyCertificates.push(cert) 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) .catch(err => console.error('[study-certificate] email exception:', err)) } diff --git a/src/App.css b/src/App.css index 10ea056..de01734 100644 --- a/src/App.css +++ b/src/App.css @@ -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; diff --git a/src/App.tsx b/src/App.tsx index 45463bb..99ed6c5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 +} + 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() { } />} /> } />} /> } />} /> + } /> } />} /> } />} /> } />} /> diff --git a/src/components/StudyCertificate.tsx b/src/components/StudyCertificate.tsx index 8f4c9c2..50e6dff 100644 --- a/src/components/StudyCertificate.tsx +++ b/src/components/StudyCertificate.tsx @@ -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(null) const [loading, setLoading] = useState(true) const [issuing, setIssuing] = useState(false) @@ -124,20 +125,27 @@ export function StudyCertificate({ studySlug }: Props) { const canvasRef = useRef(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 { diff --git a/src/studyPages.tsx b/src/studyPages.tsx index 2a7348c..b76cb32 100644 --- a/src/studyPages.tsx +++ b/src/studyPages.tsx @@ -2872,6 +2872,7 @@ export function StudyAccountPage() { const [pwMessage, setPwMessage] = useState('') const [pwSuccess, setPwSuccess] = useState(false) + const [certOpen, setCertOpen] = useState>({}) const [signOutBusy, setSignOutBusy] = useState(false) const [signOutAllBusy, setSignOutAllBusy] = useState(false) const [signOutAllMessage, setSignOutAllMessage] = useState('') @@ -3298,18 +3299,41 @@ export function StudyAccountPage() { })} - {overview.certificates.length > 0 && ( + {enrolledProgress.length > 0 && (

Certificates

- {overview.certificates.map(cert => ( -
- 🏅 {cert.studyTitle} — {new Date(cert.issuedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} -
- View - -
-
- ))} +

Finish every lesson in a study to earn a certificate of completion you can view, download and share.

+ {enrolledProgress.map(item => { + const cert = overview.certificates.find(c => c.studySlug === item.slug) + const ready = !cert && item.completedLessons >= item.totalLessons + return ( +
{ + const isOpen = (e.currentTarget as HTMLDetailsElement).open + setCertOpen(prev => (prev[item.slug] === isOpen ? prev : { ...prev, [item.slug]: isOpen })) + }} + > + + {cert ? '🏅' : '🎓'} {item.title} + {cert + ? Earned {new Date(cert.issuedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} + : ready + ? Ready to claim + : {item.completedLessons} of {item.totalLessons} lessons} + + {cert && ( +
+ View certificate + +
+ )} + { void refreshOverview() }} /> +
+ ) + })}
)}