Rework Q&A: admin inbox with one-step publish, topics and scripture references; simpler public page with ask-a-question; stop exposing asker emails; v2.22.0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-12 14:27:17 -04:00
1 parent e6bef529e6
commit 960ee41375
9 files changed
+1170 -2392

No files matched your search

+4 -381
View File
@@ -9,6 +9,7 @@ import { DEFAULTS } from './content'
import { AnalyticsPanel } from './components/AnalyticsPanel'
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
import { AdminDashboardExtras } from './components/AdminDashboardExtras'
import { QuestionsAdmin, type AdminQuestion } from './components/QuestionsAdmin'
import { useAutosave } from './hooks/useAutosave'
import QRCode from 'qrcode'
import InboxPanel, { type InboxSubmission } from './InboxPanel'
@@ -685,19 +686,7 @@ interface OpsStatus {
deployHook: { ok: boolean; at: string | null; error: string | null }
}
interface Question {
id: string
submittedAt: string
firstName: string
email: string
question: string
answer: string
answeredAt: string | null
isApproved: boolean
approvedAt: string | null
upvotes?: number
pinned?: boolean
}
type Question = AdminQuestion
type ContactSubmission = InboxSubmission
@@ -2285,12 +2274,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [questions, setQuestions] = useState<Question[]>([])
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({})
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null)
const [questionSearch, setQuestionSearch] = useState('')
const [questionFilter, setQuestionFilter] = useState<'all' | 'pending' | 'approved' | 'answered' | 'unanswered'>('all')
const [questionPage, setQuestionPage] = useState(0)
const [selectedQuestionIds, setSelectedQuestionIds] = useState<Set<string>>(new Set())
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [navSearch, setNavSearch] = useState('')
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
@@ -2382,15 +2365,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [nlPublishingId, setNlPublishingId] = useState<string | null>(null)
const [dashboardNow, setDashboardNow] = useState(() => new Date())
const [manualQuestion, setManualQuestion] = useState({
firstName: '',
email: '',
question: '',
answer: '',
approve: false,
})
const [manualQuestionStatus, setManualQuestionStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [manualQuestionMsg, setManualQuestionMsg] = useState('')
const [previewOpen, setPreviewOpen] = useState(false)
const previewIframeRef = useRef<HTMLIFrameElement>(null)
const dragSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
@@ -2644,10 +2618,6 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
}
useEffect(() => {
setQuestionPage(0)
}, [questionSearch, questionFilter])
async function reloadBackups() {
const r = await fetch('/api/admin-stats/backups')
if (!r.ok) throw new Error('Could not refresh backups')
@@ -3545,63 +3515,6 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
}))
}
async function handleAnswerQuestion(questionId: string, answer: string) {
if (!answer.trim()) return
try {
const res = await fetch(`/api/admin-questions/${questionId}/answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer: answer.trim() }),
})
if (!res.ok) throw new Error('Failed to answer question')
setQuestions(qs =>
qs.map(q =>
q.id === questionId
? { ...q, answer: answer.trim(), answeredAt: new Date().toISOString() }
: q
)
)
setEditingQuestionId(null)
setAnsweredQuestions(a => ({ ...a, [questionId]: '' }))
} catch {
alert('Failed to save answer')
}
}
async function handleApproveQuestion(questionId: string, approved: boolean) {
try {
const res = await fetch(`/api/admin-questions/${questionId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved }),
})
if (!res.ok) throw new Error('Failed to update question')
setQuestions(qs =>
qs.map(q =>
q.id === questionId
? { ...q, isApproved: approved, approvedAt: approved ? new Date().toISOString() : null }
: q
)
)
} catch {
alert('Failed to update question')
}
}
async function handlePinQuestion(questionId: string, pin: boolean) {
try {
const method = pin ? 'POST' : 'DELETE'
const res = await fetch(`/api/admin-questions/${questionId}/pin`, { method })
if (!res.ok) throw new Error('Failed to update pin')
const data = await res.json().catch(() => ({})) as { question?: Question }
if (data.question) {
setQuestions(items => items.map(q => q.id === questionId ? { ...q, pinned: data.question!.pinned } : q))
}
} catch (err) {
console.error(err)
}
}
async function loadWebhooksAdmin() {
try {
const res = await fetch('/api/admin-webhooks')
@@ -3852,102 +3765,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
} catch { alert('Failed to delete script.') }
}
async function handleDeleteQuestion(questionId: string) {
if (!confirm('Delete this question permanently?')) return
try {
const res = await fetch(`/api/admin-questions/${questionId}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete question')
setQuestions(qs => qs.filter(q => q.id !== questionId))
setSelectedQuestionIds(prev => { const next = new Set(prev); next.delete(questionId); return next })
} catch {
alert('Failed to delete question')
}
}
async function handleBulkDeleteQuestions() {
if (selectedQuestionIds.size === 0) return
if (!confirm(`Delete ${selectedQuestionIds.size} question${selectedQuestionIds.size === 1 ? '' : 's'} permanently?`)) return
const ids = Array.from(selectedQuestionIds)
let deletedCount = 0
for (const id of ids) {
try {
const res = await fetch(`/api/admin-questions/${id}`, { method: 'DELETE' })
if (res.ok) {
deletedCount++
setQuestions(qs => qs.filter(q => q.id !== id))
}
} catch {
// continue with remaining
}
}
setSelectedQuestionIds(new Set())
if (deletedCount < ids.length) alert(`Deleted ${deletedCount} of ${ids.length} questions.`)
}
async function handleCreateManualQuestion() {
if (!manualQuestion.firstName.trim() || !manualQuestion.question.trim()) {
setManualQuestionStatus('error')
setManualQuestionMsg('First name and question are required.')
return
}
setManualQuestionStatus('saving')
setManualQuestionMsg('')
try {
const res = await fetch('/api/admin-questions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: manualQuestion.firstName,
email: manualQuestion.email,
question: manualQuestion.question,
answer: manualQuestion.answer,
approve: manualQuestion.approve,
}),
})
const data = await res.json().catch(() => ({})) as { question?: Question; message?: string }
if (!res.ok || !data.question) {
throw new Error(data.message ?? 'Failed to add question.')
}
setQuestions(items => [data.question as Question, ...items])
setManualQuestion({ firstName: '', email: '', question: '', answer: '', approve: false })
setManualQuestionStatus('saved')
setManualQuestionMsg('Question added to draft Q&A list.')
} catch (err) {
setManualQuestionStatus('error')
setManualQuestionMsg(err instanceof Error ? err.message : 'Failed to add question.')
}
}
const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources')
const filteredAdminQuestions = questions.filter(question => {
const search = questionSearch.trim().toLowerCase()
const matchesSearch = !search
|| question.firstName.toLowerCase().includes(search)
|| question.question.toLowerCase().includes(search)
|| question.answer.toLowerCase().includes(search)
const matchesFilter = (
questionFilter === 'all'
|| (questionFilter === 'pending' && !question.isApproved)
|| (questionFilter === 'approved' && question.isApproved)
|| (questionFilter === 'answered' && Boolean(question.answer?.trim()))
|| (questionFilter === 'unanswered' && !question.answer?.trim())
)
return matchesSearch && matchesFilter
})
const QUESTION_PAGE_SIZE = 20
const totalQuestionPages = Math.max(1, Math.ceil(filteredAdminQuestions.length / QUESTION_PAGE_SIZE))
const visibleAdminQuestions = filteredAdminQuestions.slice(
questionPage * QUESTION_PAGE_SIZE,
(questionPage + 1) * QUESTION_PAGE_SIZE,
)
function renderSaveStatus() {
return (
<>
@@ -4169,7 +3988,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<div className="admin-dashboard-card-value">{unansweredCount}</div>
<div className="admin-dashboard-card-label">Unanswered Questions</div>
<div className="admin-dashboard-card-sub">{answeredCount} answered · {approvedCount} approved of {questions.length}</div>
{unansweredCount > 0 && <button type="button" className="admin-dashboard-card-action" onClick={() => { setQuestionFilter('unanswered'); navigateTo('questions') }}>Answer Now →</button>}
{unansweredCount > 0 && <button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('questions')}>Answer Now →</button>}
</div>
<div className={`admin-dashboard-card${unreadEmailCount > 0 ? ' admin-dashboard-card--alert' : ''}`}>
<div className="admin-dashboard-card-value">{unreadEmailCount}</div>
@@ -6840,203 +6659,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
)}
{/* QUESTIONS */}
{adminView === 'questions' && (
<section className="admin-panel-section" aria-label="Q&A Management">
<div className="admin-panel-head">
<h2>Bible Questions &amp; Answers</h2>
<p>Manage submitted questions, add manual questions, provide answers, and approve for public display.</p>
</div>
<div className="admin-actions admin-actions--maintenance" style={{ alignItems: 'center' }}>
<input
type="text"
value={questionSearch}
onChange={e => setQuestionSearch(e.target.value)}
placeholder="Search by name, question, or answer..."
style={{ minWidth: '320px', maxWidth: '520px', width: '100%' }}
/>
<button type="button" className={`btn-admin-reset${questionFilter === 'all' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('all')}>All</button>
<button type="button" className={`btn-admin-reset${questionFilter === 'pending' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('pending')}>Pending</button>
<button type="button" className={`btn-admin-reset${questionFilter === 'approved' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('approved')}>Approved</button>
<button type="button" className={`btn-admin-reset${questionFilter === 'answered' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('answered')}>Answered</button>
<button type="button" className={`btn-admin-reset${questionFilter === 'unanswered' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('unanswered')}>Unanswered</button>
</div>
<p className="admin-stats-note">Showing {filteredAdminQuestions.length} of {questions.length} questions.</p>
{selectedQuestionIds.size > 0 && (
<div className="admin-bulk-toolbar">
<span>{selectedQuestionIds.size} selected</span>
<button type="button" className="btn-admin-remove" onClick={handleBulkDeleteQuestions}>Delete Selected</button>
<button type="button" className="btn-admin-reset" onClick={() => setSelectedQuestionIds(new Set())}>Deselect All</button>
</div>
)}
<div className="admin-visits-table-wrap">
<h3>Add Question Manually</h3>
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor="manual-question-first-name">First Name</label>
<input
id="manual-question-first-name"
type="text"
value={manualQuestion.firstName}
onChange={e => setManualQuestion(curr => ({ ...curr, firstName: e.target.value }))}
placeholder="Nate"
/>
</div>
<div className="admin-field">
<label htmlFor="manual-question-email">Email (optional)</label>
<input
id="manual-question-email"
type="email"
value={manualQuestion.email}
onChange={e => setManualQuestion(curr => ({ ...curr, email: e.target.value }))}
placeholder="optional@email.com"
/>
</div>
<div className="admin-field">
<label htmlFor="manual-question-question">Question</label>
<textarea
id="manual-question-question"
rows={3}
value={manualQuestion.question}
onChange={e => setManualQuestion(curr => ({ ...curr, question: e.target.value }))}
placeholder="Type the question you want to add to the Q&A list..."
/>
</div>
<div className="admin-field">
<label htmlFor="manual-question-answer">Answer (optional)</label>
<textarea
id="manual-question-answer"
rows={3}
value={manualQuestion.answer}
onChange={e => setManualQuestion(curr => ({ ...curr, answer: e.target.value }))}
placeholder="Optional: add an answer now"
/>
</div>
<div className="admin-field">
<label htmlFor="manual-question-approve">Approve Immediately</label>
<select
id="manual-question-approve"
value={manualQuestion.approve ? 'yes' : 'no'}
onChange={e => setManualQuestion(curr => ({ ...curr, approve: e.target.value === 'yes' }))}
>
<option value="no">No</option>
<option value="yes">Yes</option>
</select>
</div>
</div>
<button
type="button"
className="btn-admin-save"
onClick={handleCreateManualQuestion}
disabled={manualQuestionStatus === 'saving'}
>
{manualQuestionStatus === 'saving' ? 'Adding…' : 'Add Question'}
</button>
</div>
{manualQuestionMsg && (
<p className={`admin-status ${manualQuestionStatus === 'error' ? 'admin-status--err' : 'admin-status--ok'}`}>
{manualQuestionMsg}
</p>
)}
</div>
{questions.length === 0 ? (
<p className="admin-stats-note">No questions submitted yet.</p>
) : filteredAdminQuestions.length === 0 ? (
<p className="admin-stats-note">No questions match this filter.</p>
) : (
<div className="admin-questions-list">
{visibleAdminQuestions.map(question => (
<div key={question.id} className={`admin-question-card${selectedQuestionIds.has(question.id) ? ' admin-question-card--selected' : ''}`}>
<div className="admin-question-header">
<label className="admin-question-checkbox" title="Select question">
<input
type="checkbox"
checked={selectedQuestionIds.has(question.id)}
onChange={e => {
setSelectedQuestionIds(prev => {
const next = new Set(prev)
if (e.target.checked) next.add(question.id)
else next.delete(question.id)
return next
})
}}
/>
</label>
<div>
<p className="admin-question-meta"><strong>{question.firstName}</strong> • {formatDate(question.submittedAt)}</p>
<p className="admin-question-text"><strong>Q:</strong> {question.question}</p>
</div>
<div className="admin-question-status">
<span className={`admin-badge ${question.isApproved ? 'admin-badge--approved' : 'admin-badge--pending'}`}>
{question.isApproved ? 'Approved' : 'Pending'}
</span>
{question.answer && <span className="admin-badge admin-badge--answered">Answered</span>}
</div>
</div>
{question.answer && (
<div className="admin-question-answer">
<p><strong>A:</strong> {question.answer}</p>
</div>
)}
{editingQuestionId === question.id ? (
<div className="admin-question-editor">
<textarea
value={answeredQuestions[question.id] ?? question.answer ?? ''}
onChange={e => setAnsweredQuestions(a => ({ ...a, [question.id]: e.target.value }))}
rows={4}
placeholder="Type your answer here..."
/>
<div className="admin-question-editor-actions">
<button type="button" className="btn-admin-save" onClick={() => handleAnswerQuestion(question.id, answeredQuestions[question.id] ?? '')}>Save Answer</button>
<button type="button" className="btn-admin-reset" onClick={() => setEditingQuestionId(null)}>Cancel</button>
</div>
</div>
) : (
<div className="admin-question-actions">
<button type="button" className="btn-admin-reset" onClick={() => { setEditingQuestionId(question.id); setAnsweredQuestions(a => ({ ...a, [question.id]: question.answer ?? '' })) }}>
{question.answer ? 'Edit Answer' : 'Add Answer'}
</button>
<button type="button" className={`btn-admin-${question.isApproved ? 'remove' : 'reset'}`} onClick={() => handleApproveQuestion(question.id, !question.isApproved)}>
{question.isApproved ? 'Unapprove' : 'Approve'}
</button>
<button type="button" className={`btn-admin-${question.pinned ? 'remove' : 'reset'}`} onClick={() => handlePinQuestion(question.id, !question.pinned)}>
{question.pinned ? '📌 Unpin' : '📌 Pin'}
</button>
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteQuestion(question.id)}>Delete</button>
</div>
)}
</div>
))}
</div>
)}
{filteredAdminQuestions.length > QUESTION_PAGE_SIZE && (
<div className="qa-pagination" style={{ marginTop: '1rem', justifyContent: 'flex-start' }}>
<button
type="button"
className="qa-page-btn"
onClick={() => setQuestionPage(p => Math.max(0, p - 1))}
disabled={questionPage === 0}
>
Prev
</button>
<span className="qa-page-info">Page {questionPage + 1} / {totalQuestionPages}</span>
<button
type="button"
className="qa-page-btn"
onClick={() => setQuestionPage(p => Math.min(totalQuestionPages - 1, p + 1))}
disabled={questionPage >= totalQuestionPages - 1}
>
Next
</button>
</div>
)}
</section>
)}
{adminView === 'questions' && <QuestionsAdmin questions={questions} onChange={setQuestions} />}
{/* STUDY USERS */}
{adminView === 'study-users' && <StudyUsersPanel studies={normalizeStudies(form)} />}