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:
nmemmert
2026-06-09 09:10:29 -04:00
parent 9ad24df626
commit c4645e3475
24 changed files with 2991 additions and 76 deletions
+72 -4
View File
@@ -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 &amp; Cache Status</h2>
+36
View File
@@ -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>
)
}
+155
View File
@@ -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>
)
}
+44 -2
View File
@@ -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"
+250
View File
@@ -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>
)
}
+150
View File
@@ -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>
)
}