Beta: admin assets, resource download forms, and resource page redesign
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user