Fix contact form crash, analytics range filter, Scripture Index parsing, and unsafe-delete UI bugs
Publish Container / docker (push) Successful in 13m12s

- server/routes/contact.js: import fireWebhooks (was undefined, throwing on every submission)
- server/routes/analytics.js: filter admin-stats by row.at, the field recordVisitor actually sets, instead of the never-populated row.visitedAt
- src/App.tsx: parseSIRef now supports chapter-spanning verse ranges (e.g. "5:15-6:2"); SI_BOOK_MAP indexes both spaced and unspaced book names so multi-word Bible Book values from the admin field resolve correctly
- src/App.css / src/App.tsx: fix Episodes/Studies nav dropdown menus being unclickable — a positioning gap between the trigger and menu created a hover dead zone; menu is now flush with spacing moved into padding so hover carries through
- src/components/StudySectionComments.tsx: check response status before removing a comment locally, so a rejected delete no longer appears to succeed
- src/components/QASection.tsx: don't bail out of search scoring for 1-2 character queries, so short substrings like "OT" or "AI" can still match
- src/AdminPage.tsx: check response status in study-user, QR code, and episode-script delete handlers before updating local state

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Yvx4rPVy2HWe7ubuk3X3J
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-07 09:39:37 -04:00
1 parent 6ed29ea527
commit c158fb6317
7 files changed
+76 -27

No files matched your search

+2 -2
View File
@@ -222,8 +222,8 @@ export function register(app) {
// Filter per-path stats by range — approximate using recent visitor rows scoped to range // Filter per-path stats by range — approximate using recent visitor rows scoped to range
const rangeVisitorRows = state.visitorStats.recentVisits.filter(row => { const rangeVisitorRows = state.visitorStats.recentVisits.filter(row => {
if (!row.visitedAt) return true // include if no timestamp if (!row.at) return true // include if no timestamp
return row.visitedAt >= cutoffDate return row.at >= cutoffDate
}) })
const rangePathCountsReal = {} const rangePathCountsReal = {}
+1
View File
@@ -15,6 +15,7 @@ import {
queueDraftQuestionsWrite, queueDraftQuestionsWrite,
normalizeContactEmailStatus, normalizeContactEmailStatus,
normalizeMessageType, normalizeMessageType,
fireWebhooks,
} from '../data.js' } from '../data.js'
import { import {
noteContactEmailCooldown, noteContactEmailCooldown,
+11 -7
View File
@@ -174,11 +174,12 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
async function handleDelete(id: string) { async function handleDelete(id: string) {
try { try {
await fetch(`/api/admin/study-users/${id}`, { method: 'DELETE' }) const res = await fetch(`/api/admin/study-users/${id}`, { method: 'DELETE' })
if (!res.ok) { flashMsg(id, 'Failed to delete user.'); return }
setUsers(prev => prev.filter(u => u.id !== id)) setUsers(prev => prev.filter(u => u.id !== id))
setConfirmDeleteId(null) setConfirmDeleteId(null)
setExpandedId(null) setExpandedId(null)
} catch { /* ignore */ } } catch { flashMsg(id, 'Failed to delete user.') }
} }
const filtered = users.filter(u => const filtered = users.filter(u =>
@@ -2479,19 +2480,21 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
async function handleDeleteQrCode(id: string) { async function handleDeleteQrCode(id: string) {
if (!confirm('Delete this QR code and all its scan history?')) return if (!confirm('Delete this QR code and all its scan history?')) return
try { try {
await fetch(`/api/admin/qr-codes/${id}`, { method: 'DELETE' }) const res = await fetch(`/api/admin/qr-codes/${id}`, { method: 'DELETE' })
if (!res.ok) { alert('Failed to delete QR code.'); return }
setQrCodes(prev => prev.filter(c => c.id !== id)) setQrCodes(prev => prev.filter(c => c.id !== id))
setQrScans(prev => prev.filter(s => s.qrId !== id)) setQrScans(prev => prev.filter(s => s.qrId !== id))
} catch { /* ignore */ } } catch { alert('Failed to delete QR code.') }
} }
async function handleClearQrScans(id: string) { async function handleClearQrScans(id: string) {
if (!confirm('Clear all scan history for this QR code?')) return if (!confirm('Clear all scan history for this QR code?')) return
try { try {
await fetch(`/api/admin/qr-codes/${id}/scans`, { method: 'DELETE' }) const res = await fetch(`/api/admin/qr-codes/${id}/scans`, { method: 'DELETE' })
if (!res.ok) { alert('Failed to clear scan history.'); return }
setQrScans(prev => prev.filter(s => s.qrId !== id)) setQrScans(prev => prev.filter(s => s.qrId !== id))
setQrCodes(prev => prev.map(c => c.id === id ? { ...c, scanCount: 0 } : c)) setQrCodes(prev => prev.map(c => c.id === id ? { ...c, scanCount: 0 } : c))
} catch { /* ignore */ } } catch { alert('Failed to clear scan history.') }
} }
async function loadStudyComments() { async function loadStudyComments() {
@@ -2568,7 +2571,8 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
async function handleDeleteScript(episodeNumber: string) { async function handleDeleteScript(episodeNumber: string) {
if (!confirm(`Remove script for Episode ${episodeNumber}?`)) return if (!confirm(`Remove script for Episode ${episodeNumber}?`)) return
try { try {
await fetch(`/api/admin-episode-scripts/${encodeURIComponent(episodeNumber)}`, { method: 'DELETE' }) const res = await fetch(`/api/admin-episode-scripts/${encodeURIComponent(episodeNumber)}`, { method: 'DELETE' })
if (!res.ok) { alert('Failed to delete script.'); return }
setScriptsList(prev => prev.filter(s => s.episodeNumber !== episodeNumber)) setScriptsList(prev => prev.filter(s => s.episodeNumber !== episodeNumber))
} catch { alert('Failed to delete script.') } } catch { alert('Failed to delete script.') }
} }
+17 -4
View File
@@ -9665,15 +9665,24 @@
.nav-dropdown-menu { .nav-dropdown-menu {
display: none; display: none;
position: absolute; position: absolute;
top: calc(100% + 0.5rem); /* Flush against the trigger (no gap) so the hover state carries continuously
from the trigger into the menu — a visual gap here left a dead zone where
the mouse exits .nav-dropdown before reaching the menu, closing it before
it could be clicked. The 0.5rem of breathing room is added as padding-top
(still inside the hoverable box) instead of a position offset. */
top: 100%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
padding-top: 0.5rem;
min-width: 160px;
z-index: 200;
}
.nav-dropdown-menu-inner {
background: rgba(15, 15, 12, 0.98); background: rgba(15, 15, 12, 0.98);
border: 1px solid rgba(201, 168, 76, 0.25); border: 1px solid rgba(201, 168, 76, 0.25);
border-radius: 8px; border-radius: 8px;
padding: 0.4rem 0; padding: 0.4rem 0;
min-width: 160px;
z-index: 200;
box-shadow: 0 8px 24px rgba(0,0,0,0.5); box-shadow: 0 8px 24px rgba(0,0,0,0.5);
} }
@@ -9923,12 +9932,16 @@
display: none; display: none;
position: static; position: static;
transform: none; transform: none;
padding-top: 0;
min-width: 0;
}
.nav-dropdown-menu-inner {
background: transparent; background: transparent;
border: none; border: none;
border-radius: 0; border-radius: 0;
padding: 0; padding: 0;
box-shadow: none; box-shadow: none;
min-width: 0;
} }
.nav-dropdown:hover .nav-dropdown-menu { .nav-dropdown:hover .nav-dropdown-menu {
+36 -11
View File
@@ -423,8 +423,10 @@ function SiteHeader({ content }: { content: SiteContent }) {
</button> </button>
</div> </div>
<div className="nav-dropdown-menu"> <div className="nav-dropdown-menu">
<NavLink to="/episodes" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Current Series</NavLink> <div className="nav-dropdown-menu-inner">
<NavLink to="/finished" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Finished Books</NavLink> <NavLink to="/episodes" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Current Series</NavLink>
<NavLink to="/finished" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Finished Books</NavLink>
</div>
</div> </div>
</div> </div>
<NavLink to="/questions" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&amp;A</NavLink> <NavLink to="/questions" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&amp;A</NavLink>
@@ -437,8 +439,10 @@ function SiteHeader({ content }: { content: SiteContent }) {
</button> </button>
</div> </div>
<div className="nav-dropdown-menu"> <div className="nav-dropdown-menu">
<NavLink to="/study" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>All Studies</NavLink> <div className="nav-dropdown-menu-inner">
<NavLink to="/scripture" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Scripture Index</NavLink> <NavLink to="/study" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>All Studies</NavLink>
<NavLink to="/scripture" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Scripture Index</NavLink>
</div>
</div> </div>
</div> </div>
<NavLink to="/study/account" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>My Account</NavLink> <NavLink to="/study/account" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>My Account</NavLink>
@@ -2052,18 +2056,31 @@ const SI_BOOK_MAP: Record<string, string> = {}
const SI_BOOK_NAME: Record<string, string> = {} const SI_BOOK_NAME: Record<string, string> = {}
const SI_BOOK_ORDER: string[] = [] const SI_BOOK_ORDER: string[] = []
for (const b of BIBLE_BOOKS) { for (const b of BIBLE_BOOKS) {
SI_BOOK_MAP[b.name.toLowerCase()] = b.id // Admin's "Bible Book" field strips all whitespace (e.g. "1corinthians"), so
for (const alias of b.aliases ?? []) SI_BOOK_MAP[alias] = b.id // index both the spaced and unspaced forms of every name/alias.
for (const key of [b.name.toLowerCase(), ...(b.aliases ?? [])]) {
SI_BOOK_MAP[key] = b.id
SI_BOOK_MAP[key.replace(/\s+/g, '')] = b.id
}
SI_BOOK_NAME[b.id] = b.name SI_BOOK_NAME[b.id] = b.name
SI_BOOK_ORDER.push(b.id) SI_BOOK_ORDER.push(b.id)
} }
function parseSIRef(ref: string) { function parseSIRef(ref: string) {
const m = ref.match(/^(.*?)\s+(\d+):(\d+)(?:-(\d+))?$/) // Supports same-chapter ranges ("5:15-20") and chapter-spanning ranges ("5:15-6:2").
const m = ref.match(/^(.*?)\s+(\d+):(\d+)(?:-(?:(\d+):)?(\d+))?$/)
if (!m) return null if (!m) return null
const bookId = SI_BOOK_MAP[m[1].toLowerCase().replace(/\.\s*/g, ' ').trim()] const bookId = SI_BOOK_MAP[m[1].toLowerCase().replace(/\.\s*/g, ' ').trim()]
if (!bookId) return null if (!bookId) return null
return { bookId, chapter: parseInt(m[2], 10), verseStart: parseInt(m[3], 10), verseEnd: m[4] ? parseInt(m[4], 10) : parseInt(m[3], 10) } const chapter = parseInt(m[2], 10)
const endChapter = m[4] ? parseInt(m[4], 10) : chapter
return {
bookId,
chapter,
endChapter,
verseStart: parseInt(m[3], 10),
verseEnd: m[5] ? parseInt(m[5], 10) : parseInt(m[3], 10),
}
} }
type HellaoContent = Array<{ type: string; number?: number; content?: Array<{ text?: string } | string> }> type HellaoContent = Array<{ type: string; number?: number; content?: Array<{ text?: string } | string> }>
@@ -2094,6 +2111,7 @@ interface ScriptureRow {
displayRef: string displayRef: string
bookId: string | null bookId: string | null
chapter: number chapter: number
endChapter: number
verseStart: number verseStart: number
verseEnd: number verseEnd: number
sources: ScriptureSource[] sources: ScriptureSource[]
@@ -2134,17 +2152,21 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
: entry.sourceTitle, : entry.sourceTitle,
} }
const key = parsed const key = parsed
? `${parsed.bookId}-${parsed.chapter}-${parsed.verseStart}-${parsed.verseEnd}` ? `${parsed.bookId}-${parsed.chapter}-${parsed.verseStart}-${parsed.endChapter}-${parsed.verseEnd}`
: `other-${entry.reference.trim().toLowerCase()}` : `other-${entry.reference.trim().toLowerCase()}`
let row = rowMap.get(key) let row = rowMap.get(key)
if (!row) { if (!row) {
const spansChapters = parsed && parsed.endChapter !== parsed.chapter
row = { row = {
key, key,
displayRef: parsed displayRef: parsed
? `${SI_BOOK_NAME[parsed.bookId]} ${parsed.chapter}:${parsed.verseStart}${parsed.verseEnd !== parsed.verseStart ? `-${parsed.verseEnd}` : ''}` ? spansChapters
? `${SI_BOOK_NAME[parsed.bookId]} ${parsed.chapter}:${parsed.verseStart}-${parsed.endChapter}:${parsed.verseEnd}`
: `${SI_BOOK_NAME[parsed.bookId]} ${parsed.chapter}:${parsed.verseStart}${parsed.verseEnd !== parsed.verseStart ? `-${parsed.verseEnd}` : ''}`
: entry.reference.trim(), : entry.reference.trim(),
bookId: parsed?.bookId ?? null, bookId: parsed?.bookId ?? null,
chapter: parsed?.chapter ?? 0, chapter: parsed?.chapter ?? 0,
endChapter: parsed?.endChapter ?? parsed?.chapter ?? 0,
verseStart: parsed?.verseStart ?? 0, verseStart: parsed?.verseStart ?? 0,
verseEnd: parsed?.verseEnd ?? 0, verseEnd: parsed?.verseEnd ?? 0,
sources: [], sources: [],
@@ -2182,7 +2204,10 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
for (const row of group.rows) { for (const row of group.rows) {
const ck = `${group.bookId}/${row.chapter}` const ck = `${group.bookId}/${row.chapter}`
if (!chapterMap.has(ck)) chapterMap.set(ck, []) if (!chapterMap.has(ck)) chapterMap.set(ck, [])
chapterMap.get(ck)!.push({ key: row.key, verseStart: row.verseStart, verseEnd: row.verseEnd }) // Chapter-spanning refs (e.g. "5:15-6:2") preview only the starting chapter's
// verses, since extractVerseRange operates on a single fetched chapter.
const verseEnd = row.endChapter !== row.chapter ? Number.MAX_SAFE_INTEGER : row.verseEnd
chapterMap.get(ck)!.push({ key: row.key, verseStart: row.verseStart, verseEnd })
} }
} }
for (const [ck, rows] of chapterMap) { for (const [ck, rows] of chapterMap) {
+3 -1
View File
@@ -349,8 +349,10 @@ function semanticScoreForQuestion(question: PublicQuestion, tags: string[], quer
const normalized = query.trim().toLowerCase() const normalized = query.trim().toLowerCase()
if (!normalized) return 0 if (!normalized) return 0
// Note: tokenize() drops tokens of length <= 2 (e.g. "OT", "AI"), so queryTokens can be
// empty for short queries — fall through to the substring checks below rather than
// bailing out, so short queries can still match via direct substring inclusion.
const queryTokens = tokenize(normalized) const queryTokens = tokenize(normalized)
if (queryTokens.length === 0) return 0
const expanded = expandQueryTokens(queryTokens) const expanded = expandQueryTokens(queryTokens)
const questionTokens = new Set(tokenize(`${question.question} ${question.answer} ${question.topic ?? ''} ${tags.join(' ')}`)) const questionTokens = new Set(tokenize(`${question.question} ${question.answer} ${question.topic ?? ''} ${tags.join(' ')}`))
+6 -2
View File
@@ -71,10 +71,14 @@ export function StudySectionComments({ studySlug, sectionId, isEnrolled }: Props
async function handleDelete(commentId: string) { async function handleDelete(commentId: string) {
setDeletingId(commentId) setDeletingId(commentId)
try { try {
await fetch(`/api/study-comments/${commentId}`, { method: 'DELETE' }) const res = await fetch(`/api/study-comments/${commentId}`, { method: 'DELETE' })
if (!res.ok) {
setError('Could not delete that comment.')
return
}
setComments(prev => prev.filter(c => c.id !== commentId)) setComments(prev => prev.filter(c => c.id !== commentId))
} catch { } catch {
// ignore setError('Network error. Please try again.')
} finally { } finally {
setDeletingId(null) setDeletingId(null)
} }