Implement email tab redesign, archive support, manual Q&A creation, URL linking, and question admin tools
- Add dedicated Emails tab with inbox/archive views and two-pane layout - Implement archive/unarchive for contact submissions with persisted state - Add manual question creation endpoint and admin form (non-contact origin) - Implement URL auto-linking in Q&A answers with safe rendering - Add question admin tools: search, filter (All/Pending/Approved/Answered/Unanswered), pagination - Expand admin panel widths to reduce cramping - Restore and enhance Asset Manager table layout - Update email reply template with fixed from-address and HTML support
This commit is contained in:
+717
-27
@@ -93,6 +93,52 @@ interface Question {
|
||||
isApproved: boolean
|
||||
approvedAt: string | null
|
||||
}
|
||||
|
||||
interface ContactSubmission {
|
||||
id: string
|
||||
submittedAt: string
|
||||
name: string
|
||||
email: string
|
||||
message: string
|
||||
messageType: 'question' | 'testimony' | 'topic' | 'general'
|
||||
subscribe: boolean
|
||||
archived?: boolean
|
||||
}
|
||||
|
||||
interface ContactReplyDraft {
|
||||
submissionId: string
|
||||
recipientName: string
|
||||
recipientEmail: string
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
|
||||
interface ContactReplyTemplate {
|
||||
id: string
|
||||
label: string
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
|
||||
interface ContactReplyHistoryItem {
|
||||
id: string
|
||||
submissionId: string
|
||||
toEmail: string
|
||||
toName: string
|
||||
fromEmail: string
|
||||
subject: string
|
||||
preview: string
|
||||
sentAt: string
|
||||
}
|
||||
|
||||
interface ContactReplyConfig {
|
||||
fromEmail: string
|
||||
fromIdentity: string
|
||||
resendApiConfigured: boolean
|
||||
canSendReplies: boolean
|
||||
note: string
|
||||
}
|
||||
|
||||
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards'>
|
||||
|
||||
type AdminView =
|
||||
@@ -100,6 +146,7 @@ type AdminView =
|
||||
| 'current-series' | 'episode-highlights' | 'archived-series'
|
||||
| 'downloads' | 'custom-links' | 'content-blocks'
|
||||
| 'questions' | 'analytics' | 'assets'
|
||||
| 'emails'
|
||||
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
|
||||
|
||||
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'global'
|
||||
@@ -251,8 +298,31 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [assetUploadPending, setAssetUploadPending] = useState(false)
|
||||
|
||||
const [questions, setQuestions] = useState<Question[]>([])
|
||||
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
|
||||
const [contactStatus, setContactStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [contactReplyDraft, setContactReplyDraft] = useState<ContactReplyDraft | null>(null)
|
||||
const [contactReplyStatus, setContactReplyStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
|
||||
const [contactReplyMsg, setContactReplyMsg] = useState('')
|
||||
const [contactReplyTemplates, setContactReplyTemplates] = useState<ContactReplyTemplate[]>([])
|
||||
const [contactReplyHistory, setContactReplyHistory] = useState<ContactReplyHistoryItem[]>([])
|
||||
const [contactReplyConfig, setContactReplyConfig] = useState<ContactReplyConfig | null>(null)
|
||||
const [contactTemplateStatus, setContactTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [emailMailboxView, setEmailMailboxView] = useState<'inbox' | 'archived'>('inbox')
|
||||
const [selectedEmailId, setSelectedEmailId] = useState<string | null>(null)
|
||||
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 [manualQuestion, setManualQuestion] = useState({
|
||||
firstName: '',
|
||||
email: '',
|
||||
question: '',
|
||||
answer: '',
|
||||
approve: false,
|
||||
})
|
||||
const [manualQuestionStatus, setManualQuestionStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [manualQuestionMsg, setManualQuestionMsg] = useState('')
|
||||
const [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({})
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const previewIframeRef = useRef<HTMLIFrameElement>(null)
|
||||
@@ -312,6 +382,38 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
setQuestions((data as { questions: Question[] }).questions ?? [])
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
fetch('/api/admin-contact-submissions')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load contact submissions'))))
|
||||
.then(data => {
|
||||
setContactSubmissions((data as { submissions: ContactSubmission[] }).submissions ?? [])
|
||||
setContactStatus('ready')
|
||||
})
|
||||
.catch(() => {
|
||||
setContactStatus('error')
|
||||
})
|
||||
|
||||
fetch('/api/admin-contact-reply-templates')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply templates'))))
|
||||
.then(data => {
|
||||
setContactReplyTemplates((data as { templates: ContactReplyTemplate[] }).templates ?? [])
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
fetch('/api/admin-contact-reply-history')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply history'))))
|
||||
.then(data => {
|
||||
setContactReplyHistory((data as { items: ContactReplyHistoryItem[] }).items ?? [])
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
fetch('/api/admin-reply-config')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply config'))))
|
||||
.then(data => {
|
||||
setContactReplyConfig(data as ContactReplyConfig)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
fetch('/api/admin-stats/backups')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load backups'))))
|
||||
.then(data => {
|
||||
@@ -375,6 +477,43 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
setStatsStatus('ready')
|
||||
}
|
||||
|
||||
async function reloadContactSubmissions() {
|
||||
const r = await fetch('/api/admin-contact-submissions')
|
||||
if (!r.ok) throw new Error('Could not refresh contact submissions')
|
||||
const data = await r.json() as { submissions?: ContactSubmission[] }
|
||||
setContactSubmissions(Array.isArray(data.submissions) ? data.submissions : [])
|
||||
setContactStatus('ready')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const visible = contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
|
||||
if (visible.length === 0) {
|
||||
setSelectedEmailId(null)
|
||||
return
|
||||
}
|
||||
if (!selectedEmailId || !visible.some(item => item.id === selectedEmailId)) {
|
||||
setSelectedEmailId(visible[0].id)
|
||||
}
|
||||
}, [contactSubmissions, emailMailboxView, selectedEmailId])
|
||||
|
||||
useEffect(() => {
|
||||
setQuestionPage(0)
|
||||
}, [questionSearch, questionFilter])
|
||||
|
||||
async function reloadContactReplyHistory() {
|
||||
const r = await fetch('/api/admin-contact-reply-history')
|
||||
if (!r.ok) throw new Error('Could not refresh reply history')
|
||||
const data = await r.json() as { items?: ContactReplyHistoryItem[] }
|
||||
setContactReplyHistory(Array.isArray(data.items) ? data.items : [])
|
||||
}
|
||||
|
||||
async function reloadContactReplyTemplates() {
|
||||
const r = await fetch('/api/admin-contact-reply-templates')
|
||||
if (!r.ok) throw new Error('Could not refresh reply templates')
|
||||
const data = await r.json() as { templates?: ContactReplyTemplate[] }
|
||||
setContactReplyTemplates(Array.isArray(data.templates) ? data.templates : [])
|
||||
}
|
||||
|
||||
async function reloadBackups() {
|
||||
const r = await fetch('/api/admin-stats/backups')
|
||||
if (!r.ok) throw new Error('Could not refresh backups')
|
||||
@@ -1237,11 +1376,200 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
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.')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteContactSubmission(submissionId: string) {
|
||||
if (!confirm('Delete this contact submission permanently?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error('Failed to delete submission')
|
||||
await reloadContactSubmissions()
|
||||
await reloadStats()
|
||||
setMaintenanceMsg('Contact submission deleted.')
|
||||
} catch {
|
||||
setMaintenanceMsg('Failed to delete contact submission.')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchiveContactSubmission(submissionId: string, archived: boolean) {
|
||||
try {
|
||||
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ archived }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update submission')
|
||||
await reloadContactSubmissions()
|
||||
await reloadStats()
|
||||
setContactReplyMsg(archived ? 'Message archived.' : 'Message moved back to inbox.')
|
||||
} catch {
|
||||
setContactReplyMsg('Failed to update archive status.')
|
||||
}
|
||||
}
|
||||
|
||||
function openContactReplyComposer(submission: ContactSubmission) {
|
||||
const firstName = submission.name?.trim().split(/\s+/)[0] || 'there'
|
||||
setContactReplyDraft({
|
||||
submissionId: submission.id,
|
||||
recipientName: submission.name,
|
||||
recipientEmail: submission.email,
|
||||
subject: 'Thanks for reaching out to Verse by Verse with Nate',
|
||||
message: `Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally.`,
|
||||
})
|
||||
setContactReplyStatus('idle')
|
||||
setContactReplyMsg(`Composing a reply to ${firstName}.`)
|
||||
}
|
||||
|
||||
function applyContactReplyTemplate(templateId: string) {
|
||||
if (!contactReplyDraft) return
|
||||
const template = contactReplyTemplates.find(item => item.id === templateId)
|
||||
if (!template) return
|
||||
|
||||
setContactReplyDraft({
|
||||
...contactReplyDraft,
|
||||
subject: template.subject,
|
||||
message: template.message,
|
||||
})
|
||||
setContactReplyMsg(`Applied template: ${template.label}.`)
|
||||
}
|
||||
|
||||
function addContactReplyTemplate() {
|
||||
setContactReplyTemplates(items => ([
|
||||
...items,
|
||||
{
|
||||
id: Date.now().toString(36),
|
||||
label: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
},
|
||||
]))
|
||||
setContactTemplateStatus('idle')
|
||||
}
|
||||
|
||||
function updateContactReplyTemplate(id: string, field: keyof ContactReplyTemplate, value: string) {
|
||||
setContactReplyTemplates(items => items.map(item => item.id === id ? { ...item, [field]: value } : item))
|
||||
setContactTemplateStatus('idle')
|
||||
}
|
||||
|
||||
function removeContactReplyTemplate(id: string) {
|
||||
setContactReplyTemplates(items => items.filter(item => item.id !== id))
|
||||
setContactTemplateStatus('idle')
|
||||
}
|
||||
|
||||
async function handleSaveContactReplyTemplates() {
|
||||
setContactTemplateStatus('saving')
|
||||
try {
|
||||
const res = await fetch('/api/admin-contact-reply-templates', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ templates: contactReplyTemplates }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to save templates')
|
||||
await reloadContactReplyTemplates()
|
||||
setContactTemplateStatus('saved')
|
||||
setContactReplyMsg('Reply templates saved.')
|
||||
} catch {
|
||||
setContactTemplateStatus('error')
|
||||
setContactReplyMsg('Failed to save reply templates.')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendContactReply() {
|
||||
if (!contactReplyDraft) return
|
||||
setContactReplyStatus('sending')
|
||||
setContactReplyMsg('')
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(contactReplyDraft.submissionId)}/reply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subject: contactReplyDraft.subject,
|
||||
message: contactReplyDraft.message,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { message?: string }).message ?? 'Failed to send email.')
|
||||
}
|
||||
|
||||
setContactReplyStatus('sent')
|
||||
setContactReplyMsg(`Reply sent to ${contactReplyDraft.recipientEmail} from hello@versebyversewithnate.us.`)
|
||||
await reloadContactReplyHistory()
|
||||
setContactReplyDraft(null)
|
||||
} catch (err) {
|
||||
setContactReplyStatus('error')
|
||||
setContactReplyMsg(err instanceof Error ? err.message : 'Failed to send email.')
|
||||
}
|
||||
}
|
||||
|
||||
const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources')
|
||||
const archivedResourceCount = (form.archivedSeries ?? []).reduce((count, series) => {
|
||||
return count + (series.resourceLinks ?? []).length
|
||||
}, 0)
|
||||
|
||||
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 (
|
||||
<>
|
||||
@@ -1324,8 +1652,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
<div className="admin-nav-group">
|
||||
<span className="admin-nav-label">Manage</span>
|
||||
<button type="button" className={`admin-nav-item${adminView === 'questions' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('questions')}>Questions ({questions.length})</button>
|
||||
<button type="button" className={`admin-nav-item${adminView === 'emails' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('emails')}>Emails</button>
|
||||
<button type="button" className={`admin-nav-item${adminView === 'analytics' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('analytics')}>Analytics</button>
|
||||
<button type="button" className={`admin-nav-item${adminView === 'assets' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('assets')}>Assets</button>
|
||||
<button type="button" className={`admin-nav-item${adminView === 'assets' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('assets')}>Asset Manager</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-nav-group">
|
||||
@@ -2018,13 +2347,104 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
<section className="admin-panel-section" aria-label="Q&A Management">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Bible Questions & Answers</h2>
|
||||
<p>Manage submitted questions, provide answers, and approve for public display.</p>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
{questions.map(question => (
|
||||
{visibleAdminQuestions.map(question => (
|
||||
<div key={question.id} className="admin-question-card">
|
||||
<div className="admin-question-header">
|
||||
<div>
|
||||
@@ -2071,6 +2491,28 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -2204,6 +2646,231 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* EMAILS */}
|
||||
{adminView === 'emails' && (
|
||||
<section className="admin-panel-section" aria-label="Email center">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Email Center</h2>
|
||||
<p>Manage inbound contact emails, archive threads, reply with templates, and review sent history.</p>
|
||||
</div>
|
||||
|
||||
{contactReplyConfig && (
|
||||
<div className="admin-restore-preview" style={{ marginBottom: '0.9rem' }}>
|
||||
<h3>Sender Status</h3>
|
||||
<p><strong>From:</strong> {contactReplyConfig.fromIdentity}</p>
|
||||
<p><strong>Resend Configured:</strong> {contactReplyConfig.resendApiConfigured ? 'Yes' : 'No'}</p>
|
||||
<p><strong>Can Send Replies:</strong> {contactReplyConfig.canSendReplies ? 'Yes' : 'No'}</p>
|
||||
<p>{contactReplyConfig.note}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-actions admin-actions--maintenance" style={{ marginBottom: '0.75rem' }}>
|
||||
<button type="button" className={`btn-admin-reset${emailMailboxView === 'inbox' ? ' btn-admin-reset--active' : ''}`} onClick={() => setEmailMailboxView('inbox')}>Inbox</button>
|
||||
<button type="button" className={`btn-admin-reset${emailMailboxView === 'archived' ? ' btn-admin-reset--active' : ''}`} onClick={() => setEmailMailboxView('archived')}>Archived</button>
|
||||
</div>
|
||||
|
||||
{contactStatus === 'loading' && <p className="admin-stats-note">Loading emails…</p>}
|
||||
{contactStatus === 'error' && <p className="admin-stats-note">Could not load contact submissions.</p>}
|
||||
|
||||
{contactStatus === 'ready' && (
|
||||
<div className="admin-email-layout">
|
||||
<aside className="admin-email-list">
|
||||
{contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true)).length === 0 && (
|
||||
<p className="admin-stats-note">No messages in this mailbox.</p>
|
||||
)}
|
||||
{contactSubmissions
|
||||
.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
|
||||
.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`admin-email-list-item${selectedEmailId === item.id ? ' admin-email-list-item--active' : ''}`}
|
||||
onClick={() => setSelectedEmailId(item.id)}
|
||||
>
|
||||
<div className="admin-email-list-head">
|
||||
<strong>{item.name}</strong>
|
||||
<span>{formatDate(item.submittedAt)}</span>
|
||||
</div>
|
||||
<p>{item.message}</p>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<div className="admin-email-detail">
|
||||
{(() => {
|
||||
const visible = contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
|
||||
const selected = visible.find(item => item.id === selectedEmailId) ?? null
|
||||
if (!selected) return <p className="admin-stats-note">Select an email to view details.</p>
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="admin-email-meta">
|
||||
<p><strong>From:</strong> {selected.name} <{selected.email}></p>
|
||||
<p><strong>Type:</strong> {selected.messageType}</p>
|
||||
<p><strong>Subscribed:</strong> {selected.subscribe ? 'Yes' : 'No'}</p>
|
||||
<p><strong>Received:</strong> {formatDate(selected.submittedAt)}</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-email-body">
|
||||
<p>{selected.message}</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-actions admin-actions--maintenance">
|
||||
<button type="button" className="btn-admin-save admin-email-action-btn" onClick={() => openContactReplyComposer(selected)}>Reply</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-reset admin-email-action-btn"
|
||||
onClick={() => handleArchiveContactSubmission(selected.id, !(selected.archived === true))}
|
||||
>
|
||||
{selected.archived === true ? 'Move to Inbox' : 'Archive'}
|
||||
</button>
|
||||
<button type="button" className="btn-admin-remove admin-email-action-btn" onClick={() => handleDeleteContactSubmission(selected.id)}>Delete</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contactReplyDraft && (
|
||||
<div className="admin-array-row" style={{ marginTop: '1rem' }}>
|
||||
<div className="admin-array-fields">
|
||||
{contactReplyTemplates.length > 0 && (
|
||||
<div className="admin-field">
|
||||
<label htmlFor="reply-template">Saved Template</label>
|
||||
<select id="reply-template" defaultValue="" onChange={e => applyContactReplyTemplate(e.target.value)}>
|
||||
<option value="">Choose a template…</option>
|
||||
{contactReplyTemplates.map(template => (
|
||||
<option key={template.id} value={template.id}>{template.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="admin-field">
|
||||
<label htmlFor="reply-to">To</label>
|
||||
<input id="reply-to" type="text" value={`${contactReplyDraft.recipientName} <${contactReplyDraft.recipientEmail}>`} readOnly />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="reply-from">From</label>
|
||||
<input id="reply-from" type="text" value="hello@versebyversewithnate.us" readOnly />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="reply-subject">Subject</label>
|
||||
<input
|
||||
id="reply-subject"
|
||||
type="text"
|
||||
value={contactReplyDraft.subject}
|
||||
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, subject: e.target.value } : draft)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="reply-message">Message</label>
|
||||
<textarea
|
||||
id="reply-message"
|
||||
rows={8}
|
||||
value={contactReplyDraft.message}
|
||||
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, message: e.target.value } : draft)}
|
||||
/>
|
||||
<p className="admin-stats-note">This will be wrapped in a professional HTML email template automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button type="button" className="btn-admin-save" onClick={handleSendContactReply} disabled={contactReplyStatus === 'sending'}>
|
||||
{contactReplyStatus === 'sending' ? 'Sending…' : 'Send Reply'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-reset"
|
||||
onClick={() => {
|
||||
setContactReplyDraft(null)
|
||||
setContactReplyStatus('idle')
|
||||
setContactReplyMsg('')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contactReplyMsg && <p className="admin-stats-note">{contactReplyMsg}</p>}
|
||||
|
||||
<details className="admin-collapsible-card">
|
||||
<summary className="admin-collapsible-summary">
|
||||
<div>
|
||||
<strong>Saved Reply Templates</strong>
|
||||
<p>{contactReplyTemplates.length} template{contactReplyTemplates.length === 1 ? '' : 's'} configured</p>
|
||||
</div>
|
||||
<span className="admin-collapsible-hint">Expand to edit</span>
|
||||
</summary>
|
||||
<div className="admin-collapsible-body">
|
||||
{contactReplyTemplates.length === 0 && <p className="admin-stats-note">No saved templates yet.</p>}
|
||||
{contactReplyTemplates.map(template => (
|
||||
<details key={template.id} className="admin-collapsible-card admin-collapsible-card--nested">
|
||||
<summary className="admin-collapsible-summary">
|
||||
<div>
|
||||
<strong>{template.label || 'Untitled template'}</strong>
|
||||
<p>{template.subject || 'No subject set'}</p>
|
||||
</div>
|
||||
<span className="admin-collapsible-hint">Expand to edit</span>
|
||||
</summary>
|
||||
<div className="admin-collapsible-body">
|
||||
<div className="admin-array-row admin-array-row--nested">
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`reply-template-label-${template.id}`}>Label</label>
|
||||
<input id={`reply-template-label-${template.id}`} type="text" value={template.label} onChange={e => updateContactReplyTemplate(template.id, 'label', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`reply-template-subject-${template.id}`}>Subject</label>
|
||||
<input id={`reply-template-subject-${template.id}`} type="text" value={template.subject} onChange={e => updateContactReplyTemplate(template.id, 'subject', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`reply-template-message-${template.id}`}>Message</label>
|
||||
<textarea id={`reply-template-message-${template.id}`} rows={5} value={template.message} onChange={e => updateContactReplyTemplate(template.id, 'message', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeContactReplyTemplate(template.id)}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
<div className="admin-actions admin-actions--maintenance">
|
||||
<button type="button" className="btn-admin-reset" onClick={addContactReplyTemplate}>Add Template</button>
|
||||
<button type="button" className="btn-admin-save" onClick={handleSaveContactReplyTemplates} disabled={contactTemplateStatus === 'saving'}>
|
||||
{contactTemplateStatus === 'saving' ? 'Saving…' : 'Save Templates'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="admin-visits-table-wrap">
|
||||
<h3>Reply History</h3>
|
||||
{contactReplyHistory.length === 0 ? <p className="admin-stats-note">No admin replies have been sent yet.</p> : (
|
||||
<div className="admin-visits-table-scroll">
|
||||
<table className="admin-visits-table">
|
||||
<thead>
|
||||
<tr><th>Sent</th><th>To</th><th>From</th><th>Subject</th><th>Preview</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{contactReplyHistory.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td>{formatDate(item.sentAt)}</td>
|
||||
<td>{item.toName} ({item.toEmail})</td>
|
||||
<td>{item.fromEmail}</td>
|
||||
<td>{item.subject}</td>
|
||||
<td>{item.preview}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ASSETS */}
|
||||
{adminView === 'assets' && (
|
||||
<section className="admin-panel-section" aria-label="Asset manager">
|
||||
@@ -2218,30 +2885,53 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</label>
|
||||
</div>
|
||||
{assets.length === 0 && <p className="admin-stats-note">No assets uploaded yet.</p>}
|
||||
<div className="admin-asset-grid">
|
||||
{assets.map(asset => (
|
||||
<div key={asset.filename} className="admin-asset-card">
|
||||
{isImageAsset(asset.filename) && <img src={asset.url} alt={asset.filename} className="admin-asset-thumb" />}
|
||||
<div className="admin-asset-meta">
|
||||
<p className="admin-asset-name">{asset.filename}</p>
|
||||
<p className="admin-asset-info">{(asset.sizeBytes / 1024).toFixed(1)} KB · {formatDate(asset.updatedAt)}</p>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-asset-tags-input"
|
||||
placeholder="Add tags…"
|
||||
value={assetTagEdits[asset.filename] ?? (asset.tags ?? []).join(', ')}
|
||||
onChange={e => setAssetTagEdits(t => ({ ...t, [asset.filename]: e.target.value }))}
|
||||
onBlur={() => handleSaveAssetTags(asset.filename)}
|
||||
/>
|
||||
<div className="admin-asset-actions">
|
||||
<button type="button" className="btn-admin-reset" onClick={() => { navigator.clipboard.writeText(asset.url).catch(() => {}) }}>Copy URL</button>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button>
|
||||
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{assets.length > 0 && (
|
||||
<div className="admin-visits-table-scroll">
|
||||
<table className="admin-visits-table admin-assets-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Preview</th>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Updated</th>
|
||||
<th>Tags</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{assets.map(asset => (
|
||||
<tr key={asset.filename}>
|
||||
<td>
|
||||
{isImageAsset(asset.filename)
|
||||
? <img src={asset.url} alt={asset.filename} className="admin-asset-thumb" style={{ width: '72px', height: '52px', objectFit: 'cover' }} />
|
||||
: <span>PDF/File</span>}
|
||||
</td>
|
||||
<td>{asset.filename}</td>
|
||||
<td>{(asset.sizeBytes / 1024).toFixed(1)} KB</td>
|
||||
<td>{formatDate(asset.updatedAt)}</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-asset-tags-input"
|
||||
placeholder="Add tags…"
|
||||
value={assetTagEdits[asset.filename] ?? (asset.tags ?? []).join(', ')}
|
||||
onChange={e => setAssetTagEdits(t => ({ ...t, [asset.filename]: e.target.value }))}
|
||||
onBlur={() => handleSaveAssetTags(asset.filename)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-asset-actions-inline">
|
||||
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => { navigator.clipboard.writeText(asset.url).catch(() => {}) }}>Copy URL</button>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
+177
-3
@@ -2333,13 +2333,14 @@
|
||||
}
|
||||
|
||||
.admin-visits-table-scroll {
|
||||
overflow-x: auto;
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.admin-visits-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 920px;
|
||||
min-width: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.admin-visits-table th,
|
||||
@@ -2350,6 +2351,9 @@
|
||||
font-family: var(--brand-font-body);
|
||||
color: var(--brand-warm-white);
|
||||
font-size: 0.9rem;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.admin-visits-table th {
|
||||
@@ -3409,6 +3413,17 @@
|
||||
line-height: 1.75;
|
||||
color: var(--brand-warm-white);
|
||||
opacity: 0.92;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.qa-answer-text a {
|
||||
color: #e0c070;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.18em;
|
||||
}
|
||||
|
||||
.qa-answer-text a:hover {
|
||||
color: #f0ead8;
|
||||
}
|
||||
|
||||
.qa-flip-hint {
|
||||
@@ -3799,7 +3814,7 @@
|
||||
}
|
||||
|
||||
.admin-panel-section {
|
||||
max-width: 740px;
|
||||
max-width: 1240px;
|
||||
}
|
||||
|
||||
.admin-panel-head {
|
||||
@@ -3941,6 +3956,165 @@
|
||||
border-color: rgba(201,168,76,0.4);
|
||||
}
|
||||
|
||||
.btn-admin-reset--compact {
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.admin-assets-table {
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.admin-assets-table th,
|
||||
.admin-assets-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-assets-table th:nth-child(1),
|
||||
.admin-assets-table td:nth-child(1) {
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.admin-assets-table th:nth-child(2),
|
||||
.admin-assets-table td:nth-child(2) {
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.admin-assets-table th:nth-child(5),
|
||||
.admin-assets-table td:nth-child(5) {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.admin-assets-table th:nth-child(6),
|
||||
.admin-assets-table td:nth-child(6) {
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.admin-asset-thumb {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(201, 168, 76, 0.25);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.admin-asset-tags-input {
|
||||
width: 100%;
|
||||
min-width: 220px;
|
||||
background: #111;
|
||||
border: 1px solid rgba(201, 168, 76, 0.24);
|
||||
border-radius: 6px;
|
||||
color: var(--brand-warm-white);
|
||||
padding: 0.5rem 0.65rem;
|
||||
}
|
||||
|
||||
.admin-asset-actions-inline {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.admin-email-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 360px) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-email-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 580px;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.admin-email-list-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: #12110d;
|
||||
border: 1px solid rgba(201, 168, 76, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.85rem;
|
||||
color: var(--brand-warm-white);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-email-list-item--active {
|
||||
border-color: rgba(201, 168, 76, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(201, 168, 76, 0.35) inset;
|
||||
}
|
||||
|
||||
.admin-email-list-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.admin-email-list-head span {
|
||||
color: var(--brand-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.admin-email-list-item p {
|
||||
margin: 0;
|
||||
color: rgba(240, 234, 216, 0.78);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-email-detail {
|
||||
background: #11100d;
|
||||
border: 1px solid rgba(201, 168, 76, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.admin-email-meta p {
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.admin-email-body {
|
||||
margin-top: 0.8rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid rgba(201, 168, 76, 0.18);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.admin-email-body p {
|
||||
margin: 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.admin-email-action-btn {
|
||||
border-radius: 10px;
|
||||
min-width: 112px;
|
||||
padding: 0.58rem 0.95rem;
|
||||
white-space: nowrap;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.03em;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.admin-email-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-email-list {
|
||||
max-height: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-sidebar {
|
||||
display: none;
|
||||
|
||||
+3
-2
@@ -1637,11 +1637,12 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionsPage() {
|
||||
function QuestionsPage({ content }: { content: SiteContent }) {
|
||||
return (
|
||||
<main className="thanks-page" aria-label="Questions and Answers">
|
||||
<div className="thanks-card" style={{ maxWidth: '1000px', width: '100%' }}>
|
||||
<QASection />
|
||||
<CustomBlocksSection content={content} page="questions" />
|
||||
<div style={{ marginTop: '2rem', textAlign: 'center' }}>
|
||||
<Link to="/" className="btn-secondary">Back to Site</Link>
|
||||
</div>
|
||||
@@ -1799,7 +1800,7 @@ export default function App() {
|
||||
<Route path="/downloads/:id" element={<DownloadDetailPage content={content} />} />
|
||||
<Route path="/about" element={<AboutPage content={content} />} />
|
||||
<Route path="/contact" element={<ContactPage content={content} />} />
|
||||
<Route path="/questions" element={<QuestionsPage />} />
|
||||
<Route path="/questions" element={<QuestionsPage content={content} />} />
|
||||
<Route path="/thanks" element={<ThankYouPage />} />
|
||||
<Route path="/subscribe" element={<SubscribePage />} />
|
||||
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
|
||||
|
||||
@@ -19,6 +19,28 @@ function tokenizeForRelated(text: string) {
|
||||
.filter(token => token.length > 3)
|
||||
}
|
||||
|
||||
function renderTextWithLinks(text: string) {
|
||||
const parts = text.split(/(https?:\/\/[^\s]+)/g)
|
||||
|
||||
return parts.map((part, index) => {
|
||||
if (!/^https?:\/\//i.test(part)) {
|
||||
return <span key={`text-${index}`}>{part}</span>
|
||||
}
|
||||
|
||||
const safeHref = part.replace(/[),.;!?]+$/g, '')
|
||||
const trailing = part.slice(safeHref.length)
|
||||
|
||||
return (
|
||||
<span key={`link-${index}`}>
|
||||
<a href={safeHref} target="_blank" rel="noopener noreferrer">
|
||||
{safeHref}
|
||||
</a>
|
||||
{trailing}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export default function QASection() {
|
||||
const [questions, setQuestions] = useState<PublicQuestion[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
@@ -158,7 +180,7 @@ export default function QASection() {
|
||||
</div>
|
||||
<div className="qa-card-face qa-card-back">
|
||||
<span className="qa-face-label">A</span>
|
||||
<p className="qa-answer-text">{question.answer}</p>
|
||||
<div className="qa-answer-text">{renderTextWithLinks(question.answer)}</div>
|
||||
{getRelatedQuestions(question).length > 0 && (
|
||||
<div className="qa-related-wrap">
|
||||
<p className="qa-related-label">Related questions</p>
|
||||
|
||||
Reference in New Issue
Block a user