Add /email route as standalone web email client; v1.1.11
Extracts the email inbox from /admin and builds it as a full-viewport email client at /email with the same admin auth. Key improvements over the old embedded panel: - Full-height split-pane layout (sidebar list + detail pane) - Subject line extracted and shown separately for inbound emails - Inline reply composer inside the detail pane (no more page-jump) - Compose-new button for outbound messages to arbitrary addresses - Search/filter across name, email, subject, and body - Unread dot indicators with localStorage tracking (marks read on open) - Keyboard navigation: ↑/↓ or j/k to move, r to reply, e to archive, Esc to close - Source badges (contact-form / inbound-email) on list items - Sent history and reply templates accessible via footer drawers - 30-second polling for new messages - New POST /api/admin-email/compose server endpoint for outbound sends - Dashboard card "Open Inbox" now links to /email Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+3
-509
@@ -11,12 +11,6 @@ import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
|
||||
import { useAutosave } from './hooks/useAutosave'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
// Addresses an admin may send a reply from — must stay in sync with ADMIN_REPLY_FROM_OPTIONS in server/config.js.
|
||||
const REPLY_FROM_OPTIONS = [
|
||||
{ value: 'Verse by Verse with Nate <hello@versebyversewithnate.us>', label: 'hello@versebyversewithnate.us' },
|
||||
{ value: 'Verse by Verse with Nate <nate@versebyversewithnate.us>', label: 'nate@versebyversewithnate.us' },
|
||||
]
|
||||
|
||||
interface SortableLessonSectionProps {
|
||||
section: ColossiansStudySection
|
||||
study: StudyProgram
|
||||
@@ -657,33 +651,6 @@ interface ContactSubmission {
|
||||
htmlBody?: string | null
|
||||
}
|
||||
|
||||
interface ContactReplyDraft {
|
||||
submissionId: string
|
||||
recipientName: string
|
||||
recipientEmail: string
|
||||
subject: string
|
||||
message: string
|
||||
fromAddress: 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 Subscriber {
|
||||
name: string
|
||||
email: string
|
||||
@@ -691,14 +658,6 @@ interface Subscriber {
|
||||
source: 'contact-form' | 'download'
|
||||
}
|
||||
|
||||
interface ContactReplyConfig {
|
||||
fromEmail: string
|
||||
fromIdentity: string
|
||||
resendApiConfigured: boolean
|
||||
canSendReplies: boolean
|
||||
note: string
|
||||
}
|
||||
|
||||
type ChecklistPhase = 'pre' | 'post'
|
||||
|
||||
interface PodcastChecklistTask {
|
||||
@@ -729,7 +688,7 @@ type AdminView =
|
||||
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist'
|
||||
| 'downloads' | 'custom-links' | 'content-blocks'
|
||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||
| 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates'
|
||||
| 'subscribers' | 'contacts' | 'study-users' | 'email-templates'
|
||||
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
|
||||
|
||||
interface AdminSectionLink {
|
||||
@@ -766,7 +725,6 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
|
||||
options: [
|
||||
{ value: 'questions', label: 'Questions' },
|
||||
{ value: 'study-comments', label: 'Study Comments' },
|
||||
{ value: 'emails', label: 'Emails' },
|
||||
{ value: 'contacts', label: 'Contacts' },
|
||||
{ value: 'subscribers', label: 'Subscribers' },
|
||||
{ value: 'study-users', label: 'Study Users' },
|
||||
@@ -1155,16 +1113,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
|
||||
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('')
|
||||
@@ -1386,30 +1334,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
.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(() => {})
|
||||
|
||||
@@ -1501,58 +1425,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
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')
|
||||
}
|
||||
|
||||
// Poll for new messages every 30 seconds — prepend new ones and refresh fields (e.g. archived) on existing ones
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/admin-contact-submissions')
|
||||
if (!r.ok) return
|
||||
const data = await r.json() as { submissions?: ContactSubmission[] }
|
||||
const fresh = Array.isArray(data.submissions) ? data.submissions : []
|
||||
setContactSubmissions(prev => {
|
||||
const freshById = new Map(fresh.map(s => [s.id, s]))
|
||||
const existingIds = new Set(prev.map(s => s.id))
|
||||
const merged = prev.map(s => freshById.get(s.id) ?? s)
|
||||
const newOnes = fresh.filter(s => !existingIds.has(s.id))
|
||||
return newOnes.length > 0 ? [...newOnes, ...merged] : merged
|
||||
})
|
||||
} catch { /* silent — don't disrupt the UI */ }
|
||||
}, 30_000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
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')
|
||||
@@ -2331,9 +2209,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
if (view === 'questions' && unansweredCount > 0) {
|
||||
return { count: unansweredCount, neutral: false }
|
||||
}
|
||||
if (view === 'emails' && unreadEmailCount > 0) {
|
||||
return { count: unreadEmailCount, neutral: false }
|
||||
}
|
||||
if (view === 'contacts' && contactSubmissions.length > 0) {
|
||||
return { count: contactSubmissions.length, neutral: true }
|
||||
}
|
||||
@@ -2837,147 +2712,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
const isInbound = submission.source === 'inbound-email'
|
||||
// For inbound emails, extract the original subject from the stored message
|
||||
const subjectLine = isInbound
|
||||
? (() => {
|
||||
const match = /^Subject: (.+)/m.exec(submission.message ?? '')
|
||||
return match ? `Re: ${match[1].trim()}` : 'Re: Your message'
|
||||
})()
|
||||
: 'Thanks for reaching out to Verse by Verse with Nate'
|
||||
// Default reply-from to whichever address the email was sent to
|
||||
const inboundTo = submission.inboundTo ?? ''
|
||||
const defaultFrom = inboundTo.includes('nate@')
|
||||
? REPLY_FROM_OPTIONS[1].value
|
||||
: REPLY_FROM_OPTIONS[0].value
|
||||
setContactReplyDraft({
|
||||
submissionId: submission.id,
|
||||
recipientName: submission.name,
|
||||
recipientEmail: submission.email,
|
||||
subject: subjectLine,
|
||||
message: '',
|
||||
fromAddress: defaultFrom,
|
||||
})
|
||||
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,
|
||||
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,
|
||||
fromAddress: contactReplyDraft.fromAddress,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { message?: string }).message ?? 'Failed to send email.')
|
||||
}
|
||||
|
||||
setContactReplyStatus('sent')
|
||||
const sentFrom = contactReplyDraft.fromAddress.match(/<([^>]+)>/)?.[1] ?? contactReplyDraft.fromAddress
|
||||
setContactReplyMsg(`Reply sent to ${contactReplyDraft.recipientEmail} from ${sentFrom}.`)
|
||||
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 filteredAdminQuestions = questions.filter(question => {
|
||||
@@ -3259,9 +2993,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</div>
|
||||
<div className={`admin-dashboard-card${unreadEmailCount > 0 ? ' admin-dashboard-card--alert' : ''}`}>
|
||||
<div className="admin-dashboard-card-value">{unreadEmailCount}</div>
|
||||
<div className="admin-dashboard-card-label">Unread Emails</div>
|
||||
<div className="admin-dashboard-card-label">Inbox Messages</div>
|
||||
<div className="admin-dashboard-card-sub">{contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total</div>
|
||||
{unreadEmailCount > 0 && <button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('emails')}>Open Inbox →</button>}
|
||||
<Link to="/email" className="admin-dashboard-card-action" style={{ textDecoration: 'none' }}>Open Inbox →</Link>
|
||||
</div>
|
||||
<div className="admin-dashboard-card">
|
||||
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
|
||||
@@ -5241,246 +4975,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
{selected.htmlBody ? (
|
||||
<iframe
|
||||
srcDoc={selected.htmlBody}
|
||||
sandbox="allow-same-origin"
|
||||
style={{ width: '100%', minHeight: '320px', border: 'none', background: '#fff', borderRadius: '4px' }}
|
||||
onLoad={e => {
|
||||
const iframe = e.currentTarget
|
||||
const h = iframe.contentDocument?.documentElement?.scrollHeight
|
||||
if (h) iframe.style.height = `${h}px`
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p style={{ whiteSpace: 'pre-wrap' }}>{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>
|
||||
<select
|
||||
id="reply-from"
|
||||
value={contactReplyDraft.fromAddress}
|
||||
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, fromAddress: e.target.value } : draft)}
|
||||
>
|
||||
{REPLY_FROM_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</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}
|
||||
placeholder="Type your reply here…"
|
||||
value={contactReplyDraft.message}
|
||||
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, message: e.target.value } : draft)}
|
||||
/>
|
||||
<div className="admin-stats-note" style={{ borderLeft: '3px solid #ccc', paddingLeft: '0.6rem', marginTop: '0.4rem', color: '#666', fontStyle: 'italic', whiteSpace: 'pre-line' }}>
|
||||
{'Grace and peace,\nVerse by Verse with Nate'}
|
||||
<span style={{ display: 'block', marginTop: '0.25rem', fontSize: '0.75em', fontStyle: 'normal', color: '#999' }}>Your signature — appended automatically</span>
|
||||
</div>
|
||||
</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>}
|
||||
|
||||
<AdminCollapsibleCard
|
||||
title="Saved Reply Templates"
|
||||
subtitle={`${contactReplyTemplates.length} template${contactReplyTemplates.length === 1 ? '' : 's'} configured`}
|
||||
>
|
||||
{contactReplyTemplates.length === 0 && <p className="admin-stats-note">No saved templates yet.</p>}
|
||||
{contactReplyTemplates.map(template => (
|
||||
<AdminCollapsibleCard
|
||||
key={template.id}
|
||||
title={template.label || 'Untitled template'}
|
||||
subtitle={template.subject || 'No subject set'}
|
||||
className="admin-collapsible-card--nested"
|
||||
>
|
||||
<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>
|
||||
</AdminCollapsibleCard>
|
||||
))}
|
||||
<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>
|
||||
</AdminCollapsibleCard>
|
||||
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user