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
const rangeVisitorRows = state.visitorStats.recentVisits.filter(row => {
if (!row.visitedAt) return true // include if no timestamp
return row.visitedAt >= cutoffDate
if (!row.at) return true // include if no timestamp
return row.at >= cutoffDate
})
const rangePathCountsReal = {}
+1
View File
@@ -15,6 +15,7 @@ import {
queueDraftQuestionsWrite,
normalizeContactEmailStatus,
normalizeMessageType,
fireWebhooks,
} from '../data.js'
import {
noteContactEmailCooldown,
+11 -7
View File
@@ -174,11 +174,12 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
async function handleDelete(id: string) {
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))
setConfirmDeleteId(null)
setExpandedId(null)
} catch { /* ignore */ }
} catch { flashMsg(id, 'Failed to delete user.') }
}
const filtered = users.filter(u =>
@@ -2479,19 +2480,21 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
async function handleDeleteQrCode(id: string) {
if (!confirm('Delete this QR code and all its scan history?')) return
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))
setQrScans(prev => prev.filter(s => s.qrId !== id))
} catch { /* ignore */ }
} catch { alert('Failed to delete QR code.') }
}
async function handleClearQrScans(id: string) {
if (!confirm('Clear all scan history for this QR code?')) return
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))
setQrCodes(prev => prev.map(c => c.id === id ? { ...c, scanCount: 0 } : c))
} catch { /* ignore */ }
} catch { alert('Failed to clear scan history.') }
}
async function loadStudyComments() {
@@ -2568,7 +2571,8 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
async function handleDeleteScript(episodeNumber: string) {
if (!confirm(`Remove script for Episode ${episodeNumber}?`)) return
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))
} catch { alert('Failed to delete script.') }
}
+17 -4
View File
@@ -9665,15 +9665,24 @@
.nav-dropdown-menu {
display: none;
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%;
transform: translateX(-50%);
padding-top: 0.5rem;
min-width: 160px;
z-index: 200;
}
.nav-dropdown-menu-inner {
background: rgba(15, 15, 12, 0.98);
border: 1px solid rgba(201, 168, 76, 0.25);
border-radius: 8px;
padding: 0.4rem 0;
min-width: 160px;
z-index: 200;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
@@ -9923,12 +9932,16 @@
display: none;
position: static;
transform: none;
padding-top: 0;
min-width: 0;
}
.nav-dropdown-menu-inner {
background: transparent;
border: none;
border-radius: 0;
padding: 0;
box-shadow: none;
min-width: 0;
}
.nav-dropdown:hover .nav-dropdown-menu {
+36 -11
View File
@@ -423,8 +423,10 @@ function SiteHeader({ content }: { content: SiteContent }) {
</button>
</div>
<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>
<NavLink to="/finished" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Finished Books</NavLink>
<div className="nav-dropdown-menu-inner">
<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>
<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>
</div>
<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>
<NavLink to="/scripture" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Scripture Index</NavLink>
<div className="nav-dropdown-menu-inner">
<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>
<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_ORDER: string[] = []
for (const b of BIBLE_BOOKS) {
SI_BOOK_MAP[b.name.toLowerCase()] = b.id
for (const alias of b.aliases ?? []) SI_BOOK_MAP[alias] = b.id
// Admin's "Bible Book" field strips all whitespace (e.g. "1corinthians"), so
// 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_ORDER.push(b.id)
}
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
const bookId = SI_BOOK_MAP[m[1].toLowerCase().replace(/\.\s*/g, ' ').trim()]
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> }>
@@ -2094,6 +2111,7 @@ interface ScriptureRow {
displayRef: string
bookId: string | null
chapter: number
endChapter: number
verseStart: number
verseEnd: number
sources: ScriptureSource[]
@@ -2134,17 +2152,21 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
: entry.sourceTitle,
}
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()}`
let row = rowMap.get(key)
if (!row) {
const spansChapters = parsed && parsed.endChapter !== parsed.chapter
row = {
key,
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(),
bookId: parsed?.bookId ?? null,
chapter: parsed?.chapter ?? 0,
endChapter: parsed?.endChapter ?? parsed?.chapter ?? 0,
verseStart: parsed?.verseStart ?? 0,
verseEnd: parsed?.verseEnd ?? 0,
sources: [],
@@ -2182,7 +2204,10 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
for (const row of group.rows) {
const ck = `${group.bookId}/${row.chapter}`
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) {
+3 -1
View File
@@ -349,8 +349,10 @@ function semanticScoreForQuestion(question: PublicQuestion, tags: string[], quer
const normalized = query.trim().toLowerCase()
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)
if (queryTokens.length === 0) return 0
const expanded = expandQueryTokens(queryTokens)
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) {
setDeletingId(commentId)
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))
} catch {
// ignore
setError('Network error. Please try again.')
} finally {
setDeletingId(null)
}