From c158fb631719153fed4e67246d51ee7e8107509b Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 7 Sep 2026 09:39:37 -0400 Subject: [PATCH] Fix contact form crash, analytics range filter, Scripture Index parsing, and unsafe-delete UI bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_016Yvx4rPVy2HWe7ubuk3X3J --- server/routes/analytics.js | 4 +-- server/routes/contact.js | 1 + src/AdminPage.tsx | 18 ++++++---- src/App.css | 21 ++++++++--- src/App.tsx | 47 +++++++++++++++++++------ src/components/QASection.tsx | 4 ++- src/components/StudySectionComments.tsx | 8 +++-- 7 files changed, 76 insertions(+), 27 deletions(-) diff --git a/server/routes/analytics.js b/server/routes/analytics.js index ba238e1..4f9dd38 100644 --- a/server/routes/analytics.js +++ b/server/routes/analytics.js @@ -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 = {} diff --git a/server/routes/contact.js b/server/routes/contact.js index e1d2810..fa8ee87 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -15,6 +15,7 @@ import { queueDraftQuestionsWrite, normalizeContactEmailStatus, normalizeMessageType, + fireWebhooks, } from '../data.js' import { noteContactEmailCooldown, diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 4ba71fe..4b88d64 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -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) { 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) { 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.') } } diff --git a/src/App.css b/src/App.css index 0193bba..a31584b 100644 --- a/src/App.css +++ b/src/App.css @@ -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 { diff --git a/src/App.tsx b/src/App.tsx index b1d29e0..090027d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -423,8 +423,10 @@ function SiteHeader({ content }: { content: SiteContent }) {
- `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Current Series - `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Finished Books +
+ `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Current Series + `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Finished Books +
`header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&A @@ -437,8 +439,10 @@ function SiteHeader({ content }: { content: SiteContent }) {
- `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>All Studies - `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Scripture Index +
+ `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>All Studies + `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Scripture Index +
`header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>My Account @@ -2052,18 +2056,31 @@ const SI_BOOK_MAP: Record = {} const SI_BOOK_NAME: Record = {} 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) { diff --git a/src/components/QASection.tsx b/src/components/QASection.tsx index 9d6fa92..40a5784 100644 --- a/src/components/QASection.tsx +++ b/src/components/QASection.tsx @@ -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(' ')}`)) diff --git a/src/components/StudySectionComments.tsx b/src/components/StudySectionComments.tsx index 52aa664..551859b 100644 --- a/src/components/StudySectionComments.tsx +++ b/src/components/StudySectionComments.tsx @@ -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) }