Add cross-site improvements across three phases
Phase 1 — Quick wins: - Image lazy-loading on series/resource cards - Newsletter signup added to Episodes page (before highlights) - Per-route meta tags via usePageMeta hook (title, og:title, og:description) - Breadcrumbs on study index, section, and notes pages - SVG completion checkmark badges on study section list - Analytics time-range filter (7d / 30d / 90d) in admin panel Phase 2 — Medium features: - Related episodes on archived series detail pages - Resource library two-tier filter (type + tag chips) - Global search (Fuse.js) moved below sticky header as full-width bar - Q&A anonymous upvoting with localStorage dedup + admin pin/unpin - Study enrollment funnel tracking (firstVisitAt, firstCompletionAt) with funnel chart in analytics Phase 3 — Larger features: - Study section comments (auto-approve for enrolled users, admin moderation panel) - Study completion certificate (canvas render, PNG download, shareable public URL) - Episode script full-text search (mammoth docx extraction, server-side search, admin upload UI) - Reflection questions renamed from Discussion Questions; quiz answers can be shared to section discussion - Public certificate route at /certificate/:token with og meta tags - Comment moderation panel added to admin under Manage > Study Comments Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,8 @@ ChartJS.register(
|
||||
interface Props {
|
||||
stats: AdminStats | null
|
||||
statsStatus: 'loading' | 'error' | 'ready'
|
||||
statsRange?: '7d' | '30d' | '90d'
|
||||
onRangeChange?: (range: '7d' | '30d' | '90d') => void
|
||||
opsStatus: { buildCommit: string | null; buildNumber: string | null; deployedAt: string | null; cachePurge: { ok: boolean; at: string | null; error: string | null }; deployHook: { ok: boolean; at: string | null; error: string | null } } | null
|
||||
formatDate: (date: string | null) => string
|
||||
maskIp: (ip: string) => string
|
||||
@@ -51,6 +53,8 @@ interface Props {
|
||||
export function AnalyticsPanel({
|
||||
stats,
|
||||
statsStatus,
|
||||
statsRange = '30d',
|
||||
onRangeChange,
|
||||
opsStatus,
|
||||
formatDate,
|
||||
maskIp,
|
||||
@@ -224,6 +228,9 @@ export function AnalyticsPanel({
|
||||
},
|
||||
}
|
||||
|
||||
const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? totalHits
|
||||
const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? statsRange
|
||||
|
||||
return (
|
||||
<section className="admin-panel-section" aria-label="Analytics">
|
||||
<div className="admin-panel-head">
|
||||
@@ -231,22 +238,51 @@ export function AnalyticsPanel({
|
||||
<p>Real-time insights into site traffic, bot detection, visitor behavior, and data management.</p>
|
||||
</div>
|
||||
|
||||
{/* Time-range tabs */}
|
||||
{onRangeChange && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{(['7d', '30d', '90d'] as const).map(r => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => onRangeChange(r)}
|
||||
style={{
|
||||
padding: '0.35rem 1rem',
|
||||
borderRadius: '20px',
|
||||
border: `1px solid ${statsRange === r ? '#c9a84c' : '#3a3320'}`,
|
||||
background: statsRange === r ? 'rgba(201,168,76,0.15)' : 'transparent',
|
||||
color: statsRange === r ? '#c9a84c' : '#7a7060',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: statsRange === r ? 600 : 400,
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{r === '7d' ? 'Last 7 days' : r === '30d' ? 'Last 30 days' : 'Last 90 days'}
|
||||
</button>
|
||||
))}
|
||||
<span style={{ color: '#5a5440', fontSize: '0.8rem', alignSelf: 'center', marginLeft: '0.25rem' }}>
|
||||
Showing: {rangeLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI Grid */}
|
||||
<div className="admin-stats-grid">
|
||||
<article>
|
||||
<h3>Total Hits</h3>
|
||||
<p>{stats.totalHits.toLocaleString()}</p>
|
||||
<small>All page requests</small>
|
||||
<p>{rangeTotalHits.toLocaleString()}</p>
|
||||
<small>{rangeLabel} all requests</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Real Traffic</h3>
|
||||
<p>{realHits.toLocaleString()}</p>
|
||||
<small>{totalHits > 0 ? ((realHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
||||
<small>{rangeTotalHits > 0 ? ((realHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Bot Traffic</h3>
|
||||
<p>{botHits.toLocaleString()}</p>
|
||||
<small>{totalHits > 0 ? ((botHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
||||
<small>{rangeTotalHits > 0 ? ((botHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Last 30 Days</h3>
|
||||
@@ -496,6 +532,38 @@ export function AnalyticsPanel({
|
||||
<article><h3>Total Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article>
|
||||
</div>
|
||||
|
||||
{/* Study Enrollment Funnel */}
|
||||
{stats.studyEnrollment?.funnel && (
|
||||
<>
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Study Enrollment Funnel</h2>
|
||||
<p>How many users progress from signup → first visit → first section completed.</p>
|
||||
</div>
|
||||
<div className="admin-funnel">
|
||||
{(() => {
|
||||
const { signups, firstVisit, firstCompletion } = stats.studyEnrollment.funnel!
|
||||
const steps = [
|
||||
{ label: 'Signed Up', count: signups, pct: 100 },
|
||||
{ label: 'Visited a Study', count: firstVisit, pct: signups > 0 ? Math.round((firstVisit / signups) * 100) : 0 },
|
||||
{ label: 'Completed a Section', count: firstCompletion, pct: signups > 0 ? Math.round((firstCompletion / signups) * 100) : 0 },
|
||||
]
|
||||
return steps.map((step, i) => (
|
||||
<div key={i} className="admin-funnel-step">
|
||||
<div className="admin-funnel-bar-wrap">
|
||||
<div className="admin-funnel-bar" style={{ width: `${step.pct}%` }} />
|
||||
</div>
|
||||
<div className="admin-funnel-label">
|
||||
<span className="admin-funnel-step-name">{step.label}</span>
|
||||
<span className="admin-funnel-count">{step.count.toLocaleString()}</span>
|
||||
<span className="admin-funnel-pct">{step.pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Deployment & Cache Status */}
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Deployment & Cache Status</h2>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="study-breadcrumbs">
|
||||
<ol className="study-breadcrumbs-list">
|
||||
{items.map((item, idx) => {
|
||||
const isLast = idx === items.length - 1
|
||||
return (
|
||||
<li key={idx} className="study-breadcrumbs-item">
|
||||
{!isLast && item.href ? (
|
||||
<Link to={item.href} className="study-breadcrumbs-link">
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="study-breadcrumbs-current" aria-current={isLast ? 'page' : undefined}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{!isLast && (
|
||||
<span className="study-breadcrumbs-sep" aria-hidden="true"> › </span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import type { SearchResult, SearchResultType } from '../hooks/useGlobalSearch'
|
||||
|
||||
const TYPE_LABEL: Record<SearchResultType, string> = {
|
||||
episode: 'Episode',
|
||||
question: 'Q&A',
|
||||
resource: 'Download',
|
||||
series: 'Series',
|
||||
}
|
||||
|
||||
const TYPE_ICON: Record<SearchResultType, string> = {
|
||||
episode: '🎙',
|
||||
question: '❓',
|
||||
resource: '📄',
|
||||
series: '📚',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
query: string
|
||||
setQuery: (q: string) => void
|
||||
results: SearchResult[]
|
||||
}
|
||||
|
||||
export function GlobalSearch({ query, setQuery, results }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeIdx, setActiveIdx] = useState(-1)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const showDropdown = open && (results.length > 0 || query.trim().length >= 2)
|
||||
|
||||
// Reset active index when results change
|
||||
useEffect(() => { setActiveIdx(-1) }, [results])
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!showDropdown) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (
|
||||
!inputRef.current?.contains(e.target as Node) &&
|
||||
!dropdownRef.current?.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [showDropdown])
|
||||
|
||||
function handleSelect(result: SearchResult) {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
if (result.type === 'episode' && result.href.startsWith('http')) {
|
||||
window.open(result.href, '_blank', 'noreferrer')
|
||||
} else {
|
||||
navigate(result.href)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (!showDropdown) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActiveIdx(i => Math.min(i + 1, results.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActiveIdx(i => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter' && activeIdx >= 0) {
|
||||
e.preventDefault()
|
||||
handleSelect(results[activeIdx])
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="global-search" role="search">
|
||||
<div className="global-search-input-wrap">
|
||||
<span className="global-search-icon" aria-hidden="true">
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||
<circle cx="6.5" cy="6.5" r="5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M10.5 10.5L14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
className="global-search-input"
|
||||
placeholder="Search episodes, Q&A, resources…"
|
||||
value={query}
|
||||
autoComplete="off"
|
||||
aria-label="Search site"
|
||||
aria-expanded={showDropdown}
|
||||
aria-autocomplete="list"
|
||||
onChange={e => { setQuery(e.target.value); setOpen(true) }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
className="global-search-clear"
|
||||
aria-label="Clear search"
|
||||
onClick={() => { setQuery(''); setOpen(false); inputRef.current?.focus() }}
|
||||
>×</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDropdown && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="global-search-dropdown"
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
>
|
||||
{results.length === 0 ? (
|
||||
<p className="global-search-empty">No results for "{query}"</p>
|
||||
) : results.map((result, idx) => (
|
||||
<button
|
||||
key={result.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={idx === activeIdx}
|
||||
className={`global-search-result${idx === activeIdx ? ' global-search-result--active' : ''}`}
|
||||
onClick={() => handleSelect(result)}
|
||||
onMouseEnter={() => setActiveIdx(idx)}
|
||||
>
|
||||
<span className="global-search-result-icon" aria-hidden="true">
|
||||
{TYPE_ICON[result.type]}
|
||||
</span>
|
||||
<span className="global-search-result-body">
|
||||
<span className="global-search-result-title">{result.title}</span>
|
||||
<span className="global-search-result-meta">
|
||||
<span className="global-search-result-type">{TYPE_LABEL[result.type]}</span>
|
||||
{result.subtitle && result.subtitle !== TYPE_LABEL[result.type] && (
|
||||
<span className="global-search-result-sub"> · {result.subtitle}</span>
|
||||
)}
|
||||
</span>
|
||||
{result.scriptSnippet && (
|
||||
<span className="global-search-result-snippet">{result.scriptSnippet}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{results.length > 0 && (
|
||||
<p className="global-search-hint">↑↓ navigate · Enter select · Esc close</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ interface PublicQuestion {
|
||||
topic?: string
|
||||
submittedAt?: string
|
||||
answeredAt?: string
|
||||
upvotes?: number
|
||||
pinned?: boolean
|
||||
}
|
||||
|
||||
interface DecoratedQuestion extends PublicQuestion {
|
||||
@@ -390,6 +392,12 @@ export default function QASection() {
|
||||
const [copiedQuestionId, setCopiedQuestionId] = useState<string | null>(null)
|
||||
const [copiedFacebookCaptionId, setCopiedFacebookCaptionId] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [votedIds, setVotedIds] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('qa-voted-ids-v1')
|
||||
return new Set(raw ? JSON.parse(raw) as string[] : [])
|
||||
} catch { return new Set() }
|
||||
})
|
||||
const resultsTopRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -404,6 +412,22 @@ export default function QASection() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
async function handleUpvote(id: string) {
|
||||
if (votedIds.has(id)) return
|
||||
try {
|
||||
const res = await fetch(`/api/questions/${encodeURIComponent(id)}/upvote`, { method: 'POST' })
|
||||
if (!res.ok) return
|
||||
const data = await res.json() as { upvotes: number }
|
||||
setQuestions(prev => prev.map(q => q.id === id ? { ...q, upvotes: data.upvotes } : q))
|
||||
setVotedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
next.add(id)
|
||||
localStorage.setItem('qa-voted-ids-v1', JSON.stringify([...next]))
|
||||
return next
|
||||
})
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setEngagement(readEngagementFromStorage())
|
||||
}, [])
|
||||
@@ -839,22 +863,40 @@ export default function QASection() {
|
||||
: question.answer
|
||||
|
||||
return (
|
||||
<article key={question.id} id={`qa-${question.id}`} className={`qa-card-scene${isFocused ? ' qa-card-scene--focused' : ''}`}>
|
||||
<article key={question.id} id={`qa-${question.id}`} className={`qa-card-scene${isFocused ? ' qa-card-scene--focused' : ''}${question.pinned ? ' qa-card-scene--pinned' : ''}`}>
|
||||
<div className="qa-question-header">
|
||||
<span className="qa-face-label">Q</span>
|
||||
<span className="qa-question-main">
|
||||
<span className="qa-question-text">{renderHighlightedText(question.question, normalizedSearch)}</span>
|
||||
<span className="qa-question-text">
|
||||
{question.pinned && (
|
||||
<span className="qa-pinned-badge" aria-label="Pinned question">📌 </span>
|
||||
)}
|
||||
{renderHighlightedText(question.question, normalizedSearch)}
|
||||
</span>
|
||||
<span className="qa-question-meta">
|
||||
<span className="qa-topic-pill">{question._topic}</span>
|
||||
{formatDate(question.answeredAt ?? question.submittedAt) && (
|
||||
<span>{formatDate(question.answeredAt ?? question.submittedAt)}</span>
|
||||
)}
|
||||
{question._helpful > 0 && <span>{question._helpful} helpful</span>}
|
||||
{(question.upvotes ?? 0) > 0 && (
|
||||
<span>👍 {question.upvotes}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="qa-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={votedIds.has(question.id) ? 'Already upvoted' : 'Upvote this question'}
|
||||
className={`qa-upvote-btn${votedIds.has(question.id) ? ' qa-upvote-btn--voted' : ''}`}
|
||||
onClick={() => handleUpvote(question.id)}
|
||||
disabled={votedIds.has(question.id)}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor" width="13" height="13"><path d="M8 2L2 9h4v5h4V9h4z"/></svg>
|
||||
<span>{votedIds.has(question.id) ? 'Upvoted' : 'Upvote'}{(question.upvotes ?? 0) > 0 ? ` · ${question.upvotes}` : ''}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Share on X"
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
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
|
||||
}
|
||||
|
||||
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 }: 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(() => {
|
||||
fetch(`/api/study-certificate/${studySlug}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
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))
|
||||
}, [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)
|
||||
} 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface Comment {
|
||||
id: string
|
||||
displayName: string
|
||||
text: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
studySlug: string
|
||||
sectionId: string
|
||||
isEnrolled: boolean
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function StudySectionComments({ studySlug, sectionId, isEnrolled }: Props) {
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [text, setText] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const textRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetch(`/api/study-comments/${studySlug}/${sectionId}`)
|
||||
.then(r => r.json())
|
||||
.then(data => setComments(Array.isArray(data.comments) ? data.comments : []))
|
||||
.catch(() => setComments([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [studySlug, sectionId])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!text.trim()) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
try {
|
||||
const res = await fetch(`/api/study-comments/${studySlug}/${sectionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: text.trim() }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.message ?? 'Failed to post comment.')
|
||||
return
|
||||
}
|
||||
setComments(prev => [...prev, data.comment])
|
||||
setText('')
|
||||
setSuccess(true)
|
||||
setTimeout(() => setSuccess(false), 3000)
|
||||
} catch {
|
||||
setError('Network error. Please try again.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(commentId: string) {
|
||||
setDeletingId(commentId)
|
||||
try {
|
||||
await fetch(`/api/study-comments/${commentId}`, { method: 'DELETE' })
|
||||
setComments(prev => prev.filter(c => c.id !== commentId))
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="study-comments">
|
||||
<h3 className="study-comments-heading">Share Your Thoughts</h3>
|
||||
|
||||
{loading ? (
|
||||
<p className="study-comments-loading">Loading comments…</p>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="study-comments-empty">No comments yet. Be the first to share a thought!</p>
|
||||
) : (
|
||||
<ul className="study-comments-list">
|
||||
{comments.map(comment => (
|
||||
<li key={comment.id} className="study-comment">
|
||||
<div className="study-comment-header">
|
||||
<span className="study-comment-author">{comment.displayName}</span>
|
||||
<span className="study-comment-date">{formatDate(comment.createdAt)}</span>
|
||||
</div>
|
||||
<p className="study-comment-text">{comment.text}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="study-comment-delete"
|
||||
onClick={() => handleDelete(comment.id)}
|
||||
disabled={deletingId === comment.id}
|
||||
aria-label="Delete comment"
|
||||
>
|
||||
{deletingId === comment.id ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{isEnrolled ? (
|
||||
<form className="study-comment-form" onSubmit={handleSubmit}>
|
||||
<label htmlFor="study-comment-input" className="study-comment-form-label">
|
||||
Leave a comment
|
||||
</label>
|
||||
<textarea
|
||||
id="study-comment-input"
|
||||
ref={textRef}
|
||||
className="study-comment-textarea"
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
placeholder="Share a thought, question, or encouragement…"
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<div className="study-comment-form-footer">
|
||||
<span className="study-comment-char-count">{text.length}/2000</span>
|
||||
{error && <span className="study-comment-error">{error}</span>}
|
||||
{success && <span className="study-comment-success">Comment posted!</span>}
|
||||
<button
|
||||
type="submit"
|
||||
className="study-comment-submit"
|
||||
disabled={submitting || !text.trim()}
|
||||
>
|
||||
{submitting ? 'Posting…' : 'Post Comment'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<p className="study-comments-enroll-note">
|
||||
<a href="/study">Enroll in this study</a> to join the discussion.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user