Fix contact form crash, analytics range filter, Scripture Index parsing, and unsafe-delete UI bugs
Publish Container / docker (push) Successful in 13m12s
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:
1 parent
6ed29ea527
commit
c158fb6317
7 files changed
+76
-27
No files matched your search
@@ -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 = {}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
queueDraftQuestionsWrite,
|
||||
normalizeContactEmailStatus,
|
||||
normalizeMessageType,
|
||||
fireWebhooks,
|
||||
} from '../data.js'
|
||||
import {
|
||||
noteContactEmailCooldown,
|
||||
|
||||
+11
-7
@@ -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
@@ -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
@@ -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&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) {
|
||||
|
||||
@@ -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(' ')}`))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user