Beta: admin assets, resource download forms, and resource page redesign

This commit is contained in:
nmemmert
2026-04-30 15:47:10 -04:00
parent 10bd6848ef
commit 7f56060d6b
14 changed files with 2000 additions and 1029 deletions
+111
View File
@@ -0,0 +1,111 @@
import { useState } from 'react'
import type { ChangeEvent } from 'react'
import { useNavigate } from 'react-router-dom'
interface ContactFields {
firstName: string
lastName: string
email: string
message: string
messageType: string
}
export default function ContactForm() {
const navigate = useNavigate()
const [fields, setFields] = useState<ContactFields>({
firstName: '',
lastName: '',
email: '',
message: '',
messageType: 'question',
})
const [subscribe, setSubscribe] = useState(true)
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
function handleSelectChange(e: ChangeEvent<HTMLSelectElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus('submitting')
setErrorMsg('')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...fields, subscribe, _honey: honey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setErrorMsg((data as { message?: string }).message ?? 'Something went wrong. Please try again.')
setStatus('error')
return
}
navigate('/thanks')
} catch {
setErrorMsg('Could not connect. Please try again later.')
setStatus('error')
}
}
return (
<form className="contact-form" onSubmit={handleSubmit} noValidate>
<input
type="text"
className="contact-honeypot"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
value={honey}
onChange={e => setHoney(e.target.value)}
/>
<label>
First Name
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
</label>
<label>
Last Name
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
</label>
<label>
Email
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
</label>
<label>
Message Type
<select name="messageType" value={fields.messageType} onChange={handleSelectChange}>
<option value="question">Bible Question</option>
<option value="testimony">Testimony</option>
<option value="topic">Topic Request</option>
<option value="general">General Message</option>
</select>
</label>
<label>
Message
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
</label>
<label className="contact-consent">
<input
type="checkbox"
checked={subscribe}
onChange={e => setSubscribe(e.target.checked)}
/>
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Sending…' : 'Send Message'}
</button>
</form>
)
}
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useState } from 'react'
import type { KeyboardEvent } from 'react'
interface PublicQuestion {
id: string
firstName: string
question: string
answer: string
topic?: string
}
const QA_PAGE_SIZE = 6
function tokenizeForRelated(text: string) {
return text
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/)
.filter(token => token.length > 3)
}
export default function QASection() {
const [questions, setQuestions] = useState<PublicQuestion[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
const [expanded, setExpanded] = useState<{ [key: string]: boolean }>({})
const [page, setPage] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/questions')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
.then(data => {
setQuestions((data as { questions: PublicQuestion[] }).questions ?? [])
setLoading(false)
})
.catch(() => {
setLoading(false)
})
}, [])
const topics = Array.from(new Set(questions.map(q => q.topic).filter(Boolean))) as string[]
const filteredQuestions = questions.filter(q => {
const matchesTopic = !selectedTopic || q.topic === selectedTopic
const matchesSearch =
!searchQuery ||
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.answer.toLowerCase().includes(searchQuery.toLowerCase())
return matchesTopic && matchesSearch
})
const totalPages = Math.ceil(filteredQuestions.length / QA_PAGE_SIZE)
const pagedQuestions = filteredQuestions.slice(page * QA_PAGE_SIZE, (page + 1) * QA_PAGE_SIZE)
const toggleExpanded = (id: string) => {
setExpanded(state => ({ ...state, [id]: !state[id] }))
}
const handleSearch = (value: string) => {
setSearchQuery(value)
setPage(0)
}
const handleTopic = (topic: string | null) => {
setSelectedTopic(topic)
setPage(0)
}
const getRelatedQuestions = (current: PublicQuestion) => {
const currentTokens = new Set(tokenizeForRelated(`${current.question} ${current.answer}`))
return questions
.filter(candidate => candidate.id !== current.id)
.map(candidate => {
const candidateTokens = tokenizeForRelated(`${candidate.question} ${candidate.answer}`)
const overlap = candidateTokens.filter(token => currentTokens.has(token)).length
const sameTopic = Boolean(current.topic && candidate.topic && current.topic === candidate.topic)
const score = overlap + (sameTopic ? 5 : 0)
return { candidate, score }
})
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 3)
.map(item => item.candidate)
}
return (
<section id="qa" className="section-qa" aria-label="Questions and answers">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> Questions & Answers <span className="ornament"></span>
</h2>
{loading ? (
<p className="qa-no-results">Loading questions</p>
) : questions.length === 0 ? (
<div className="qa-no-results">
<p>No questions have been answered yet. <a href="#contact">Submit yours below!</a></p>
</div>
) : (
<>
<div className="qa-filters">
<div className="qa-topics">
<button
className={`qa-topic-btn${selectedTopic === null ? ' qa-topic-btn--active' : ''}`}
onClick={() => handleTopic(null)}
>
All
</button>
{topics.map(topic => (
<button
key={topic}
className={`qa-topic-btn${selectedTopic === topic ? ' qa-topic-btn--active' : ''}`}
onClick={() => handleTopic(topic)}
>
{topic}
</button>
))}
</div>
<div className="qa-search">
<label>
<span className="visually-hidden">Search Questions</span>
<input
type="text"
placeholder="Search questions…"
value={searchQuery}
onChange={e => handleSearch(e.target.value)}
/>
</label>
</div>
</div>
{filteredQuestions.length === 0 ? (
<div className="qa-no-results">
<p>No matching questions found. <a href="#contact">Submit your question</a></p>
</div>
) : (
<>
<div className="qa-cards">
{pagedQuestions.map(question => (
<div key={question.id} className="qa-card-scene">
<div
className={`qa-card-inner ${expanded[question.id] ? 'flipped' : ''}`}
onClick={() => toggleExpanded(question.id)}
role="button"
tabIndex={0}
aria-expanded={!!expanded[question.id]}
aria-label={question.question}
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') toggleExpanded(question.id)
}}
>
<div className="qa-card-face qa-card-front">
<span className="qa-face-label">Q</span>
<p className="qa-question-text">{question.question}</p>
<span className="qa-flip-hint">{expanded[question.id] ? '▲' : '▼'}</span>
</div>
<div className="qa-card-face qa-card-back">
<span className="qa-face-label">A</span>
<p className="qa-answer-text">{question.answer}</p>
{getRelatedQuestions(question).length > 0 && (
<div className="qa-related-wrap">
<p className="qa-related-label">Related questions</p>
<div className="qa-related-list">
{getRelatedQuestions(question).map(related => (
<button
key={related.id}
type="button"
className="qa-related-btn"
onClick={e => {
e.stopPropagation()
handleTopic(null)
handleSearch(related.question)
setExpanded({ [related.id]: true })
}}
>
{related.question}
</button>
))}
</div>
</div>
)}
<p style={{ margin: '0.75rem 0 0', fontSize: '0.8rem', color: '#a89060', fontStyle: 'italic' }}>
Answered by Nate
</p>
</div>
</div>
</div>
))}
</div>
{totalPages > 1 && (
<div className="qa-pagination">
<button
className="qa-page-btn"
onClick={() => setPage(p => p - 1)}
disabled={page === 0}
aria-label="Previous page"
>
Prev
</button>
<span className="qa-page-info">
{page + 1} / {totalPages}
</span>
<button
className="qa-page-btn"
onClick={() => setPage(p => p + 1)}
disabled={page >= totalPages - 1}
aria-label="Next page"
>
Next
</button>
</div>
)}
</>
)}
</>
)}
</div>
</section>
)
}