v1.2.0: Broadcast emails, webhook admin UI, dynamic sitemap, scripture index
Publish Container / docker (push) Successful in 14m11s

- Admin > Audience > Broadcast Email: compose and send one-off HTML emails
  to all subscribers via Resend batch API; {{name}} personalization; confirm step
- Admin > Configure > Webhooks: CRUD UI for managing POST event webhooks;
  backend GET/POST/DELETE /api/admin-webhooks routes wired to existing state.webhooks
- Sitemap: dynamically appends released study lesson URLs (/study/:slug/:id)
  and episode highlight URLs (/episodes/:id) to /sitemap.xml
- Scripture Index: public /scripture page listing every released lesson grouped
  by study with passage reference links; served by /api/public/scripture-index

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-03 09:54:43 -04:00
1 parent 0bf2d92b1f
commit 63d4f9e316
7 files changed
+348 -3

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "1.1.52", "version": "1.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+36
View File
@@ -25,6 +25,7 @@ import {
queueReplyTemplatesWrite, queueReplyTemplatesWrite,
queueReplyHistoryWrite, queueReplyHistoryWrite,
queuePodcastChecklistWrite, queuePodcastChecklistWrite,
queueWebhooksWrite,
createBackupSnapshot, createBackupSnapshot,
listBackupPreviews, listBackupPreviews,
readBackupPreview, readBackupPreview,
@@ -331,4 +332,39 @@ export function register(app) {
res.status(400).json({ message: err instanceof Error ? err.message : 'Restore failed.' }) res.status(400).json({ message: err instanceof Error ? err.message : 'Restore failed.' })
} }
}) })
// ── Webhooks ──────────────────────────────────────────────────────────────
app.get('/api/admin-webhooks', requireAdminAuth, (_req, res) => {
res.json({ webhooks: state.webhooks })
})
app.post('/api/admin-webhooks', requireAdminAuth, (req, res) => {
const { url, events } = req.body ?? {}
if (!url || typeof url !== 'string' || !/^https:\/\//i.test(url.trim())) {
res.status(400).json({ message: 'A valid HTTPS URL is required.' })
return
}
if (state.webhooks.length >= 50) {
res.status(400).json({ message: 'Maximum of 50 webhooks reached.' })
return
}
const webhook = {
id: crypto.randomUUID(),
url: url.trim().slice(0, 500),
events: Array.isArray(events) ? events.map(String).slice(0, 20) : (typeof events === 'string' && events.trim() ? events.split(',').map(e => e.trim()).filter(Boolean) : ['*']),
createdAt: new Date().toISOString(),
}
state.webhooks.push(webhook)
queueWebhooksWrite()
res.status(201).json({ webhook })
})
app.delete('/api/admin-webhooks/:id', requireAdminAuth, (req, res) => {
const idx = state.webhooks.findIndex(w => w.id === req.params.id)
if (idx === -1) { res.status(404).json({ message: 'Not found.' }); return }
state.webhooks.splice(idx, 1)
queueWebhooksWrite()
res.json({ ok: true })
})
} }
+46
View File
@@ -386,4 +386,50 @@ export function register(app) {
res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`) res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`)
res.send(csv) res.send(csv)
}) })
app.post('/api/admin-broadcast', requireAdminAuth, async (req, res) => {
const { subject, html } = req.body ?? {}
if (!subject || typeof subject !== 'string' || !subject.trim()) {
res.status(400).json({ message: 'Subject is required.' }); return
}
if (!html || typeof html !== 'string' || !html.trim()) {
res.status(400).json({ message: 'Email body is required.' }); return
}
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
const seen = new Set()
const recipients = state.contactSubmissions
.filter(e => e.subscribe === true && e.email && !seen.has(e.email) && seen.add(e.email))
.map(e => ({ name: e.name ?? '', email: e.email }))
if (recipients.length === 0) {
res.status(400).json({ message: 'No subscribers to send to.' }); return
}
const resend = new Resend(process.env.RESEND_API_KEY)
const from = getResendFromAddress()
const replyTo = getResendReplyToAddress()
const BATCH = 50
let sent = 0
let failed = 0
for (let i = 0; i < recipients.length; i += BATCH) {
const batch = recipients.slice(i, i + BATCH)
const emails = batch.map(r => ({
from,
to: r.email,
reply_to: replyTo,
subject: subject.trim(),
html: html.replace(/\{\{name\}\}/g, escapeHtml(r.name || 'Friend')),
}))
try {
const { error } = await resend.batch.send(emails)
if (error) { failed += batch.length } else { sent += batch.length }
} catch { failed += batch.length }
}
res.json({ ok: true, sent, failed, total: recipients.length })
})
} }
+36
View File
@@ -63,6 +63,23 @@ export function register(app) {
.filter(Boolean) .filter(Boolean)
.map(item => `${root}${item}`) .map(item => `${root}${item}`)
// Dynamic study lesson URLs
const studies = Array.isArray(state.cachedSiteContent?.studies) ? state.cachedSiteContent.studies : []
const now = new Date()
for (const study of studies) {
if (!study.slug || !Array.isArray(study.sections)) continue
for (const section of study.sections) {
if (section.id && section.releasedAt && new Date(section.releasedAt) <= now) {
urls.push(`${root}/study/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`)
}
}
}
// Dynamic episode highlight URLs
const highlights = Array.isArray(state.cachedSiteContent?.podcastFeaturedLinks) ? state.cachedSiteContent.podcastFeaturedLinks : []
for (const ep of highlights) {
if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`)
}
const xml = [ const xml = [
'<?xml version="1.0" encoding="UTF-8"?>', '<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
@@ -142,6 +159,25 @@ export function register(app) {
</html>`) </html>`)
}) })
// Scripture index — all released study lessons with their passage references
app.get('/api/public/scripture-index', (_req, res) => {
const content = state.cachedSiteContent
const allStudies = Array.isArray(content?.studies) ? content.studies : []
const cutoff = new Date()
const studies = allStudies
.filter(s => s.slug && Array.isArray(s.sections))
.map(s => ({
slug: s.slug,
title: s.title || s.slug,
entries: s.sections
.filter(sec => sec.id && sec.reference && sec.releasedAt && new Date(sec.releasedAt) <= cutoff)
.map(sec => ({ sectionId: sec.id, title: sec.title || '', reference: sec.reference }))
.sort((a, b) => a.reference.localeCompare(b.reference, undefined, { numeric: true })),
}))
.filter(s => s.entries.length > 0)
res.json({ studies })
})
// SPA static files and fallback // SPA static files and fallback
app.use(express.static(DIST_DIR)) app.use(express.static(DIST_DIR))
+162 -2
View File
@@ -612,8 +612,8 @@ type AdminView =
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss' | 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources' | 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes' | 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'subscribers' | 'study-users' | 'email-templates' | 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
| 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'webhooks'
interface AdminSectionLink { interface AdminSectionLink {
id: string id: string
@@ -675,6 +675,7 @@ const ADMIN_NAV: NavGroup[] = [
{ kind: 'leaf', view: 'questions', label: 'Questions' }, { kind: 'leaf', view: 'questions', label: 'Questions' },
{ kind: 'leaf', view: 'study-comments', label: 'Study Comments' }, { kind: 'leaf', view: 'study-comments', label: 'Study Comments' },
{ kind: 'leaf', view: 'subscribers', label: 'Subscribers' }, { kind: 'leaf', view: 'subscribers', label: 'Subscribers' },
{ kind: 'leaf', view: 'broadcast', label: 'Broadcast Email' },
{ kind: 'leaf', view: 'study-users', label: 'Study Users' }, { kind: 'leaf', view: 'study-users', label: 'Study Users' },
], ],
}, },
@@ -692,6 +693,7 @@ const ADMIN_NAV: NavGroup[] = [
{ kind: 'leaf', view: 'global', label: 'Footer & Global' }, { kind: 'leaf', view: 'global', label: 'Footer & Global' },
{ kind: 'leaf', view: 'seo', label: 'SEO & Redirects' }, { kind: 'leaf', view: 'seo', label: 'SEO & Redirects' },
{ kind: 'leaf', view: 'email-templates', label: 'Email Templates' }, { kind: 'leaf', view: 'email-templates', label: 'Email Templates' },
{ kind: 'leaf', view: 'webhooks', label: 'Webhooks' },
{ kind: 'leaf', view: 'legal', label: 'Legal Pages' }, { kind: 'leaf', view: 'legal', label: 'Legal Pages' },
{ kind: 'leaf', view: 'security', label: 'Security' }, { kind: 'leaf', view: 'security', label: 'Security' },
{ kind: 'leaf', view: 'brand', label: 'Brand Kit' }, { kind: 'leaf', view: 'brand', label: 'Brand Kit' },
@@ -1114,6 +1116,22 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [selectedHighlightId, setSelectedHighlightId] = useState<string | null>(null) const [selectedHighlightId, setSelectedHighlightId] = useState<string | null>(null)
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null) const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null)
// Webhooks state
interface WebhookEntry { id: string; url: string; events: string[]; createdAt: string }
const [webhooks, setWebhooks] = useState<WebhookEntry[]>([])
const [webhooksLoaded, setWebhooksLoaded] = useState(false)
const [webhookUrl, setWebhookUrl] = useState('')
const [webhookEvents, setWebhookEvents] = useState('*')
const [webhookMsg, setWebhookMsg] = useState('')
// Broadcast state
const [broadcastSubject, setBroadcastSubject] = useState('')
const [broadcastBody, setBroadcastBody] = useState('')
const [broadcastSending, setBroadcastSending] = useState(false)
const [broadcastResult, setBroadcastResult] = useState<{ sent: number; failed: number; total: number } | null>(null)
const [broadcastMsg, setBroadcastMsg] = useState('')
const [broadcastConfirm, setBroadcastConfirm] = useState(false)
const [dashboardNow, setDashboardNow] = useState(() => new Date()) const [dashboardNow, setDashboardNow] = useState(() => new Date())
const [manualQuestion, setManualQuestion] = useState({ const [manualQuestion, setManualQuestion] = useState({
firstName: '', firstName: '',
@@ -2320,6 +2338,14 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
} }
} }
async function loadWebhooksAdmin() {
try {
const res = await fetch('/api/admin-webhooks')
const data = await res.json()
if (Array.isArray(data.webhooks)) { setWebhooks(data.webhooks); setWebhooksLoaded(true) }
} catch { /* silent */ }
}
async function loadQrCodes() { async function loadQrCodes() {
setQrLoading(true) setQrLoading(true)
setQrMsg('') setQrMsg('')
@@ -5108,6 +5134,140 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
</section> </section>
)} )}
{/* ── BROADCAST EMAIL ── */}
{adminView === 'broadcast' && (() => {
const recipientCount = (() => {
const seen = new Set<string>()
return subscribers.filter(s => s.email && !seen.has(s.email) && !!seen.add(s.email)).length
})()
async function sendBroadcast() {
if (!broadcastSubject.trim() || !broadcastBody.trim()) {
setBroadcastMsg('Subject and body are required.'); return
}
setBroadcastSending(true); setBroadcastMsg(''); setBroadcastResult(null)
try {
const r = await fetch('/api/admin-broadcast', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject: broadcastSubject, html: broadcastBody }),
})
const d = await r.json()
if (!r.ok) { setBroadcastMsg(d.message || 'Send failed.') } else {
setBroadcastResult(d); setBroadcastConfirm(false)
setBroadcastMsg(`Sent to ${d.sent} subscriber${d.sent !== 1 ? 's' : ''}${d.failed > 0 ? ` (${d.failed} failed)` : ''}.`)
}
} catch { setBroadcastMsg('Network error. Try again.') }
finally { setBroadcastSending(false) }
}
return (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Broadcast Email</h2>
<p>Compose and send a one-off email to all {subscribers.length} subscribers.</p>
</div>
<div className="admin-field">
<label htmlFor="bc-subject">Subject</label>
<input id="bc-subject" type="text" value={broadcastSubject} onChange={e => setBroadcastSubject(e.target.value)} placeholder="This week's episode + new study lesson" />
</div>
<div className="admin-field">
<label htmlFor="bc-body">Body <span style={{ fontWeight: 400, opacity: 0.6, fontSize: '0.8em' }}>(HTML — use {'{{name}}'} for personalization)</span></label>
<textarea id="bc-body" rows={14} value={broadcastBody} onChange={e => setBroadcastBody(e.target.value)} placeholder={'<p>Hi {{name}},</p>\n<p>This week we covered...</p>'} style={{ fontFamily: 'monospace', fontSize: '0.85rem' }} />
</div>
{broadcastBody.trim() && (
<details style={{ marginBottom: '1rem' }}>
<summary style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.7 }}>Preview rendered HTML</summary>
<div style={{ marginTop: '0.75rem', padding: '1rem', background: 'var(--admin-card-bg)', borderRadius: '6px', border: '1px solid var(--admin-border)', fontSize: '0.9rem' }}
dangerouslySetInnerHTML={{ __html: broadcastBody.replace(/\{\{name\}\}/g, 'Nate (preview)') }} />
</details>
)}
{broadcastMsg && <p className={broadcastResult ? 'admin-save-ok' : 'admin-save-error'} style={{ marginBottom: '0.75rem' }}>{broadcastMsg}</p>}
{!broadcastConfirm ? (
<button type="button" className="btn-admin-apply" disabled={!broadcastSubject.trim() || !broadcastBody.trim()} onClick={() => setBroadcastConfirm(true)}>
Send to {recipientCount} subscriber{recipientCount !== 1 ? 's' : ''}…
</button>
) : (
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<strong style={{ color: 'var(--brand-gold)' }}>Send "{broadcastSubject}" to {recipientCount} people?</strong>
<button type="button" className="btn-admin-apply" disabled={broadcastSending} onClick={sendBroadcast}>
{broadcastSending ? 'Sending…' : 'Confirm Send'}
</button>
<button type="button" className="btn-admin-reset" onClick={() => setBroadcastConfirm(false)}>Cancel</button>
</div>
)}
</section>
)
})()}
{/* ── WEBHOOKS ── */}
{adminView === 'webhooks' && (() => {
if (!webhooksLoaded) loadWebhooksAdmin()
const ALL_EVENTS = ['*', 'contact.submitted', 'subscriber.added', 'question.submitted', 'study.lesson.released', 'study.user.enrolled']
async function addWebhook() {
if (!webhookUrl.trim()) { setWebhookMsg('A URL is required.'); return }
setWebhookMsg('')
try {
const r = await fetch('/api/admin-webhooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: webhookUrl, events: webhookEvents.split(',').map(e => e.trim()).filter(Boolean) }),
})
const d = await r.json()
if (!r.ok) { setWebhookMsg(d.message || 'Failed to add webhook.') } else {
setWebhooks(prev => [...prev, d.webhook])
setWebhookUrl(''); setWebhookEvents('*')
setWebhookMsg('Webhook added.')
}
} catch { setWebhookMsg('Network error.') }
}
async function deleteWebhook(id: string) {
if (!confirm('Delete this webhook?')) return
try {
await fetch(`/api/admin-webhooks/${id}`, { method: 'DELETE' })
setWebhooks(prev => prev.filter(w => w.id !== id))
} catch { setWebhookMsg('Failed to delete.') }
}
return (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Webhooks</h2>
<p>Send POST notifications to external URLs when site events fire.</p>
</div>
{webhooks.length > 0 ? (
<div style={{ marginBottom: '1.5rem' }}>
{webhooks.map(wh => (
<div key={wh.id} className="study-lesson-row" style={{ alignItems: 'flex-start', padding: '0.75rem 0.5rem' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.82rem', wordBreak: 'break-all', marginBottom: '0.25rem' }}>{wh.url}</div>
<div style={{ fontSize: '0.75rem', opacity: 0.6 }}>
Events: <span style={{ fontFamily: 'monospace' }}>{(wh.events ?? ['*']).join(', ')}</span>
{' · '}{new Date(wh.createdAt).toLocaleDateString()}
</div>
</div>
<button type="button" className="btn-admin-remove" style={{ fontSize: '0.8rem', padding: '0.2rem 0.5rem', marginLeft: '0.5rem' }} onClick={() => deleteWebhook(wh.id)}>✕</button>
</div>
))}
</div>
) : (
<p className="admin-stats-note" style={{ marginBottom: '1.5rem' }}>No webhooks configured.</p>
)}
<div className="admin-panel-head" style={{ marginTop: 0 }}>
<h3 style={{ fontSize: '0.95rem' }}>Add Webhook</h3>
</div>
<div className="admin-field">
<label htmlFor="wh-url">Endpoint URL (HTTPS)</label>
<input id="wh-url" type="url" value={webhookUrl} onChange={e => setWebhookUrl(e.target.value)} placeholder="https://example.com/hook" />
</div>
<div className="admin-field">
<label htmlFor="wh-events">Events <span style={{ fontWeight: 400, opacity: 0.6, fontSize: '0.8em' }}>(comma-separated; * = all)</span></label>
<input id="wh-events" type="text" value={webhookEvents} onChange={e => setWebhookEvents(e.target.value)} placeholder="*" />
<p className="admin-field-hint" style={{ marginTop: '0.3rem' }}>Available: {ALL_EVENTS.join(', ')}</p>
</div>
{webhookMsg && <p className="admin-save-ok" style={{ marginBottom: '0.5rem' }}>{webhookMsg}</p>}
<button type="button" className="btn-admin-apply" onClick={addWebhook}>Add Webhook</button>
</section>
)
})()}
</main> </main>
{/* ── Live preview pane ── */} {/* ── Live preview pane ── */}
+17
View File
@@ -12312,3 +12312,20 @@
.kofi-qr-caption { font-size: 0.78rem; color: var(--brand-muted, #b0a48c); letter-spacing: 0.03em; } .kofi-qr-caption { font-size: 0.78rem; color: var(--brand-muted, #b0a48c); letter-spacing: 0.03em; }
.kofi-btn { display: inline-block; background: transparent; color: var(--brand-gold, #c9a84c); border: 1px solid var(--brand-gold, #c9a84c); font-size: 0.9rem; font-weight: 600; padding: 0.7rem 1.75rem; border-radius: 4px; text-decoration: none; letter-spacing: 0.04em; transition: background 0.2s, color 0.2s; } .kofi-btn { display: inline-block; background: transparent; color: var(--brand-gold, #c9a84c); border: 1px solid var(--brand-gold, #c9a84c); font-size: 0.9rem; font-weight: 600; padding: 0.7rem 1.75rem; border-radius: 4px; text-decoration: none; letter-spacing: 0.04em; transition: background 0.2s, color 0.2s; }
.kofi-btn:hover { background: var(--brand-gold, #c9a84c); color: var(--brand-black, #0a0a08); } .kofi-btn:hover { background: var(--brand-gold, #c9a84c); color: var(--brand-black, #0a0a08); }
/* ── Scripture index page ────────────────────────────────────────────────── */
.scripture-index-main { padding: 3rem 1.5rem 5rem; }
.scripture-index-note { color: var(--brand-muted, #b0a48c); margin: 2rem 0; }
.scripture-study-group { margin-bottom: 3rem; }
.scripture-study-title { font-family: var(--brand-font-heading, 'Cormorant Garamond', serif); font-size: 1.6rem; font-weight: 600; color: var(--brand-gold, #c9a84c); margin: 0 0 1.25rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--brand-border, #2a2518); }
.scripture-entry-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.25rem 1.5rem; }
.scripture-entry { margin: 0; }
.scripture-entry-link { display: flex; align-items: baseline; gap: 0.6rem; padding: 0.4rem 0; color: var(--brand-warm-white, #f0ead8); text-decoration: none; border-bottom: 1px solid transparent; transition: color 0.15s, border-color 0.15s; }
.scripture-entry-link:hover { color: var(--brand-gold, #c9a84c); border-bottom-color: var(--brand-gold, #c9a84c); }
.scripture-entry-ref { font-family: var(--brand-font-body, 'EB Garamond', serif); font-size: 0.95rem; font-weight: 600; white-space: nowrap; flex-shrink: 0; }
.scripture-entry-title { font-size: 0.82rem; color: var(--brand-muted, #b0a48c); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── Admin status messages ───────────────────────────────────────────────── */
.admin-save-ok { color: #6ebd7c; font-size: 0.85rem; margin: 0; }
.admin-save-error { color: #e05c5c; font-size: 0.85rem; margin: 0; }
.admin-field-hint { font-size: 0.78rem; color: var(--admin-muted, #a0957e); margin: 0; }
+50
View File
@@ -1925,6 +1925,55 @@ function ResourcesPage({ content }: { content: SiteContent }) {
) )
} }
function ScriptureIndexPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Scripture Index | ${siteTitle}`,
'Every passage referenced across all Bible studies — find the lesson that covers a particular verse.',
)
interface ScriptureEntry { sectionId: string; title: string; reference: string }
interface StudyGroup { slug: string; title: string; entries: ScriptureEntry[] }
const [studies, setStudies] = useState<StudyGroup[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/public/scripture-index')
.then(r => r.json())
.then(d => { if (Array.isArray(d.studies)) setStudies(d.studies) })
.catch(() => {})
.finally(() => setLoading(false))
}, [])
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Reference" title="Scripture Index" desc="Every passage covered in our studies, organized by reference. Find the lesson for any verse." />
<main className="scripture-index-main page-width">
{loading ? (
<p className="scripture-index-note">Loading…</p>
) : studies.length === 0 ? (
<p className="scripture-index-note">No study lessons are published yet.</p>
) : studies.map(study => (
<section key={study.slug} className="scripture-study-group">
<h2 className="scripture-study-title">{study.title}</h2>
<ul className="scripture-entry-list">
{study.entries.map(entry => (
<li key={entry.sectionId} className="scripture-entry">
<Link to={`/study/${study.slug}/${entry.sectionId}`} className="scripture-entry-link">
<span className="scripture-entry-ref">{entry.reference}</span>
{entry.title && <span className="scripture-entry-title">{entry.title}</span>}
</Link>
</li>
))}
</ul>
</section>
))}
</main>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
function AboutPage({ content }: { content: SiteContent }) { function AboutPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta( usePageMeta(
@@ -2711,6 +2760,7 @@ export default function App() {
<Route path="/about" element={<AboutPage content={content} />} /> <Route path="/about" element={<AboutPage content={content} />} />
<Route path="/contact" element={<ContactPage content={content} />} /> <Route path="/contact" element={<ContactPage content={content} />} />
<Route path="/questions" element={<QuestionsPage content={content} />} /> <Route path="/questions" element={<QuestionsPage content={content} />} />
<Route path="/scripture" element={<ScriptureIndexPage content={content} />} />
<Route path="/thanks" element={<ThankYouPage />} /> <Route path="/thanks" element={<ThankYouPage />} />
<Route path="/subscribe" element={<SubscribePage />} /> <Route path="/subscribe" element={<SubscribePage />} />
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} /> <Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />