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:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "1.1.10",
|
||||
"version": "1.1.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -554,6 +554,69 @@ export function register(app) {
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-email/compose', requireAdminAuth, async (req, res) => {
|
||||
try {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(503).json({ message: 'RESEND_API_KEY is not configured on the server.' }); return
|
||||
}
|
||||
const to = typeof req.body?.to === 'string' ? req.body.to.trim() : ''
|
||||
const toName = typeof req.body?.toName === 'string' ? req.body.toName.trim() : ''
|
||||
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''
|
||||
const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''
|
||||
const requestedFrom = typeof req.body?.fromAddress === 'string' ? req.body.fromAddress.trim() : ''
|
||||
|
||||
if (!to || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(to)) {
|
||||
res.status(400).json({ message: 'A valid recipient email address is required.' }); return
|
||||
}
|
||||
if (!subject || subject.length > 180) {
|
||||
res.status(400).json({ message: 'Subject is required and must be 180 characters or fewer.' }); return
|
||||
}
|
||||
if (!message || message.length > 6000) {
|
||||
res.status(400).json({ message: 'Message is required and must be 6000 characters or fewer.' }); return
|
||||
}
|
||||
|
||||
const recipientName = toName ? splitName(toName).firstName || toName : 'friend'
|
||||
const html = buildAdminReplyTemplate({ recipientName, message })
|
||||
const defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM
|
||||
const fromAddress = ADMIN_REPLY_FROM_OPTIONS.includes(requestedFrom) ? requestedFrom : defaultFrom
|
||||
const replyToAddress = getResendReplyToAddress()
|
||||
const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate`
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
|
||||
await sendResendEmailWithRetry({
|
||||
resend,
|
||||
context: 'admin-compose',
|
||||
payload: {
|
||||
from: fromAddress,
|
||||
to: [to],
|
||||
subject,
|
||||
replyTo: replyToAddress,
|
||||
tags: [{ name: 'flow', value: 'admin-reply' }],
|
||||
text,
|
||||
html,
|
||||
},
|
||||
})
|
||||
|
||||
state.replyHistory.unshift({
|
||||
id: randomUUID(),
|
||||
submissionId: '',
|
||||
toEmail: to,
|
||||
toName: toName || to,
|
||||
fromEmail: replyToAddress,
|
||||
subject,
|
||||
preview: message.slice(0, 500),
|
||||
sentAt: new Date().toISOString(),
|
||||
})
|
||||
state.replyHistory = state.replyHistory.slice(0, 500)
|
||||
queueReplyHistoryWrite()
|
||||
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[admin-compose] send error:', err)
|
||||
res.status(500).json({ message: 'Failed to send email.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
|
||||
const seen = new Set()
|
||||
const subscribers = state.contactSubmissions
|
||||
|
||||
+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">
|
||||
|
||||
+809
@@ -9253,3 +9253,812 @@
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Email Client (/email) ──────────────────────────────────────────────── */
|
||||
|
||||
.em-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: #0a0a08;
|
||||
color: #f0ead8;
|
||||
font-family: Georgia, serif;
|
||||
}
|
||||
|
||||
.em-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 1.1rem;
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.22);
|
||||
background: #0d0d0a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.em-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.em-header-brand {
|
||||
font-family: Georgia, serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #e0c070;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.em-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.em-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.em-sidebar {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid rgba(201, 168, 76, 0.18);
|
||||
overflow: hidden;
|
||||
background: #0b0b09;
|
||||
}
|
||||
|
||||
.em-search-wrap {
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.14);
|
||||
}
|
||||
|
||||
.em-search {
|
||||
width: 100%;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(201, 168, 76, 0.22);
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.65rem;
|
||||
color: #f0ead8;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 0.88rem;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.em-search:focus {
|
||||
border-color: rgba(201, 168, 76, 0.5);
|
||||
}
|
||||
|
||||
.em-search::placeholder {
|
||||
color: rgba(240, 234, 216, 0.35);
|
||||
}
|
||||
|
||||
.em-mailbox-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.14);
|
||||
}
|
||||
|
||||
.em-mailbox-tab {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.55rem 0.5rem;
|
||||
color: rgba(240, 234, 216, 0.6);
|
||||
font-family: Georgia, serif;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.em-mailbox-tab--active {
|
||||
color: #e0c070;
|
||||
border-bottom-color: #e0c070;
|
||||
}
|
||||
|
||||
.em-tab-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
background: rgba(201, 168, 76, 0.18);
|
||||
color: rgba(240, 234, 216, 0.7);
|
||||
}
|
||||
|
||||
.em-tab-badge--unread {
|
||||
background: rgba(201, 168, 76, 0.35);
|
||||
color: #e0c070;
|
||||
}
|
||||
|
||||
/* Message list */
|
||||
.em-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.4rem 0.35rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.em-list-empty {
|
||||
padding: 1.25rem 0.75rem;
|
||||
color: rgba(240, 234, 216, 0.45);
|
||||
font-size: 0.88rem;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.em-list-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: rgba(255,255,255,0.018);
|
||||
border: 1px solid rgba(201, 168, 76, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.7rem 0.6rem 1rem;
|
||||
cursor: pointer;
|
||||
color: #f0ead8;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.em-list-item:hover {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border-color: rgba(201, 168, 76, 0.25);
|
||||
}
|
||||
|
||||
.em-list-item--active {
|
||||
background: rgba(201, 168, 76, 0.08);
|
||||
border-color: rgba(201, 168, 76, 0.45);
|
||||
}
|
||||
|
||||
.em-list-item--unread .em-list-item-name {
|
||||
color: #e0c070;
|
||||
}
|
||||
|
||||
.em-unread-dot {
|
||||
position: absolute;
|
||||
left: 0.32rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #c9a84c;
|
||||
}
|
||||
|
||||
.em-list-item-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.18rem;
|
||||
}
|
||||
|
||||
.em-list-item-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: rgba(240,234,216,0.9);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.em-list-item-date {
|
||||
font-size: 0.74rem;
|
||||
color: rgba(240, 234, 216, 0.4);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.em-list-item-subject {
|
||||
font-size: 0.83rem;
|
||||
color: rgba(240, 234, 216, 0.8);
|
||||
margin-bottom: 0.14rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.em-list-item-preview {
|
||||
font-size: 0.78rem;
|
||||
color: rgba(240, 234, 216, 0.45);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.em-source-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.68rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
padding: 0.12rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
margin-top: 0.3rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.em-source-badge--inbound {
|
||||
background: rgba(3, 105, 161, 0.25);
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.em-source-badge--contact-form {
|
||||
background: rgba(22, 101, 52, 0.25);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.em-source-badge--download {
|
||||
background: rgba(109, 40, 217, 0.2);
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.em-source-badge--subscribed {
|
||||
background: rgba(201, 168, 76, 0.15);
|
||||
color: #e0c070;
|
||||
}
|
||||
|
||||
/* Detail pane */
|
||||
.em-detail {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.em-empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: rgba(240, 234, 216, 0.35);
|
||||
}
|
||||
|
||||
.em-empty-icon {
|
||||
font-size: 2.5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.em-empty-state p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.em-empty-hint {
|
||||
font-size: 0.78rem !important;
|
||||
font-family: system-ui, sans-serif;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.em-detail-header {
|
||||
padding: 0.9rem 1.15rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.16);
|
||||
flex-shrink: 0;
|
||||
background: #0d0d0a;
|
||||
}
|
||||
|
||||
.em-detail-meta-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.em-detail-from {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.em-detail-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(201, 168, 76, 0.22);
|
||||
color: #e0c070;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.em-detail-from-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #f0ead8;
|
||||
}
|
||||
|
||||
.em-detail-from-email {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(240, 234, 216, 0.5);
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.em-detail-meta-right {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.em-detail-date {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(240, 234, 216, 0.5);
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.em-detail-to {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(240, 234, 216, 0.4);
|
||||
font-family: system-ui, sans-serif;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.em-detail-subject-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.em-detail-subject {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: #f0ead8;
|
||||
}
|
||||
|
||||
.em-detail-badges {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.em-detail-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.15rem;
|
||||
}
|
||||
|
||||
.em-detail-iframe {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.em-detail-plain {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 0.93rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(240, 234, 216, 0.85);
|
||||
}
|
||||
|
||||
.em-detail-footer {
|
||||
padding: 0.65rem 1.15rem;
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.16);
|
||||
flex-shrink: 0;
|
||||
background: #0d0d0a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.em-detail-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Reply / Compose */
|
||||
.em-reply-composer {
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.25);
|
||||
background: #0d0d0a;
|
||||
flex-shrink: 0;
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.em-compose-new {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.em-compose-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 1.1rem 0.4rem;
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.14);
|
||||
}
|
||||
|
||||
.em-compose-header-label {
|
||||
font-size: 0.9rem;
|
||||
color: #e0c070;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.em-compose-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(240, 234, 216, 0.5);
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.em-compose-close:hover {
|
||||
color: #f0ead8;
|
||||
}
|
||||
|
||||
.em-compose-fields {
|
||||
padding: 0.7rem 1.1rem 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.em-compose-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.em-compose-row--inline {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.em-compose-label {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(240, 234, 216, 0.5);
|
||||
white-space: nowrap;
|
||||
min-width: 48px;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.em-compose-input {
|
||||
flex: 1;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(201, 168, 76, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 0.38rem 0.6rem;
|
||||
color: #f0ead8;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 0.88rem;
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.em-compose-input:focus {
|
||||
border-color: rgba(201, 168, 76, 0.45);
|
||||
}
|
||||
|
||||
.em-compose-select {
|
||||
flex: 1;
|
||||
background: #14130f;
|
||||
border: 1px solid rgba(201, 168, 76, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 0.38rem 0.55rem;
|
||||
color: #f0ead8;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.em-compose-body {
|
||||
width: 100%;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(201, 168, 76, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
color: #f0ead8;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.65;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.em-compose-body:focus {
|
||||
border-color: rgba(201, 168, 76, 0.45);
|
||||
}
|
||||
|
||||
.em-compose-signature {
|
||||
font-size: 0.78rem;
|
||||
color: rgba(240, 234, 216, 0.35);
|
||||
font-style: italic;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 2px solid rgba(201, 168, 76, 0.2);
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.em-compose-signature-note {
|
||||
font-size: 0.72rem;
|
||||
font-style: normal;
|
||||
font-family: system-ui, sans-serif;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.em-compose-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.em-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.42rem 0.9rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
|
||||
.em-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.em-btn--primary {
|
||||
background: #c9a84c;
|
||||
color: #0a0a08;
|
||||
font-weight: 700;
|
||||
border-color: #e0c070;
|
||||
}
|
||||
|
||||
.em-btn--primary:hover:not(:disabled) { opacity: 0.88; }
|
||||
|
||||
.em-btn--secondary {
|
||||
background: rgba(201, 168, 76, 0.12);
|
||||
color: #e0c070;
|
||||
border-color: rgba(201, 168, 76, 0.3);
|
||||
}
|
||||
|
||||
.em-btn--secondary:hover { background: rgba(201, 168, 76, 0.2); }
|
||||
|
||||
.em-btn--ghost {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: rgba(240, 234, 216, 0.7);
|
||||
border-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.em-btn--ghost:hover { background: rgba(255,255,255,0.1); color: #f0ead8; }
|
||||
|
||||
.em-btn--danger {
|
||||
background: rgba(220, 38, 38, 0.15);
|
||||
color: #fca5a5;
|
||||
border-color: rgba(220, 38, 38, 0.3);
|
||||
}
|
||||
|
||||
.em-btn--danger:hover { background: rgba(220, 38, 38, 0.25); }
|
||||
|
||||
.em-btn--sm {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.28rem 0.65rem;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.em-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.em-badge--alert {
|
||||
background: rgba(201, 168, 76, 0.25);
|
||||
color: #e0c070;
|
||||
}
|
||||
|
||||
/* Status message */
|
||||
.em-status-msg {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
color: rgba(240, 234, 216, 0.6);
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.em-status-msg--inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Drawers */
|
||||
.em-drawer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.55);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.em-drawer {
|
||||
background: #111009;
|
||||
border: 1px solid rgba(201, 168, 76, 0.22);
|
||||
border-radius: 14px 14px 0 0;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.em-drawer--wide { max-width: 820px; }
|
||||
|
||||
.em-drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.8rem 1.1rem;
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.18);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.em-drawer-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #e0c070;
|
||||
}
|
||||
|
||||
.em-drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.9rem 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.em-drawer-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Template cards */
|
||||
.em-template-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(201, 168, 76, 0.14);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* History */
|
||||
.em-history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.em-history-item {
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(201, 168, 76, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.8rem;
|
||||
}
|
||||
|
||||
.em-history-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.em-history-date {
|
||||
font-size: 0.77rem;
|
||||
color: rgba(240, 234, 216, 0.4);
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.em-history-flow {
|
||||
font-size: 0.77rem;
|
||||
color: rgba(240, 234, 216, 0.45);
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.em-history-subject {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: #f0ead8;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.em-history-preview {
|
||||
font-size: 0.82rem;
|
||||
color: rgba(240, 234, 216, 0.55);
|
||||
}
|
||||
|
||||
/* Footer toolbar */
|
||||
.em-footer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.9rem;
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.14);
|
||||
background: #0b0b09;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.em-footer-hint {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
color: rgba(240, 234, 216, 0.28);
|
||||
}
|
||||
|
||||
.em-footer-warn {
|
||||
font-size: 0.78rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 700px) {
|
||||
.em-sidebar {
|
||||
width: 100%;
|
||||
max-height: 42vh;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.18);
|
||||
}
|
||||
|
||||
.em-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.em-footer-hint { display: none; }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import AdminPage from './AdminPage'
|
||||
import EmailShell from './EmailPage'
|
||||
import QASection from './components/QASection'
|
||||
import ContactForm from './components/ContactForm'
|
||||
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy'
|
||||
@@ -2494,6 +2495,7 @@ export default function App() {
|
||||
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
|
||||
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
|
||||
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
|
||||
<Route path="/email" element={<EmailShell />} />
|
||||
<Route path="/preview" element={<PreviewPage />} />
|
||||
<Route
|
||||
path="/privacy"
|
||||
|
||||
@@ -0,0 +1,916 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ContactSubmission {
|
||||
id: string
|
||||
submittedAt: string
|
||||
name: string
|
||||
email: string
|
||||
message: string
|
||||
messageType: 'question' | 'testimony' | 'topic' | 'general'
|
||||
subscribe: boolean
|
||||
archived?: boolean
|
||||
source?: 'contact-form' | 'download' | 'inbound-email'
|
||||
inboundTo?: string
|
||||
htmlBody?: string | null
|
||||
messageId?: string
|
||||
}
|
||||
|
||||
interface ReplyDraft {
|
||||
submissionId: string | null
|
||||
recipientName: string
|
||||
recipientEmail: string
|
||||
subject: string
|
||||
message: string
|
||||
fromAddress: string
|
||||
}
|
||||
|
||||
interface ReplyTemplate {
|
||||
id: string
|
||||
label: string
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
|
||||
interface ReplyHistoryItem {
|
||||
id: string
|
||||
submissionId: string
|
||||
toEmail: string
|
||||
toName: string
|
||||
fromEmail: string
|
||||
subject: string
|
||||
preview: string
|
||||
sentAt: string
|
||||
}
|
||||
|
||||
interface ReplyConfig {
|
||||
fromEmail: string
|
||||
fromIdentity: string
|
||||
resendApiConfigured: boolean
|
||||
canSendReplies: boolean
|
||||
note: string
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function extractSubject(message: string): string {
|
||||
const m = /^Subject:\s*(.+)/m.exec(message ?? '')
|
||||
return m ? m[1].trim() : ''
|
||||
}
|
||||
|
||||
function extractBodyPreview(message: string): string {
|
||||
const afterSubject = message.replace(/^Subject:\s*.+\n+/m, '').trim()
|
||||
return afterSubject.slice(0, 200)
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '—'
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString()
|
||||
}
|
||||
|
||||
function formatShortDate(value: string | null | undefined): string {
|
||||
if (!value) return '—'
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return '—'
|
||||
const now = new Date()
|
||||
const sameDay = d.toDateString() === now.toDateString()
|
||||
if (sameDay) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
const sameYear = d.getFullYear() === now.getFullYear()
|
||||
if (sameYear) return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function readIds(): Set<string> {
|
||||
try { return new Set(JSON.parse(localStorage.getItem('em-read-ids') ?? '[]') as string[]) }
|
||||
catch { return new Set() }
|
||||
}
|
||||
|
||||
function persistReadId(id: string) {
|
||||
try {
|
||||
const next = [...readIds(), id].slice(-1000)
|
||||
localStorage.setItem('em-read-ids', JSON.stringify(next))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── EmailClient ──────────────────────────────────────────────────────────────
|
||||
|
||||
function EmailClient({ onLogout }: { onLogout: () => void }) {
|
||||
const [submissions, setSubmissions] = useState<ContactSubmission[]>([])
|
||||
const [loadStatus, setLoadStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [mailbox, setMailbox] = useState<'inbox' | 'archived'>('inbox')
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [localReadIds, setLocalReadIds] = useState<Set<string>>(readIds)
|
||||
const [replyDraft, setReplyDraft] = useState<ReplyDraft | null>(null)
|
||||
const [replySending, setReplySending] = useState(false)
|
||||
const [replyMsg, setReplyMsg] = useState('')
|
||||
const [templates, setTemplates] = useState<ReplyTemplate[]>([])
|
||||
const [history, setHistory] = useState<ReplyHistoryItem[]>([])
|
||||
const [config, setConfig] = useState<ReplyConfig | null>(null)
|
||||
const [templateStatus, setTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [showTemplatesMgr, setShowTemplatesMgr] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [actionMsg, setActionMsg] = useState('')
|
||||
const composeRef = useRef<HTMLTextAreaElement>(null)
|
||||
const listRef = useRef<HTMLElement>(null)
|
||||
|
||||
// ── Load ──
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
try {
|
||||
const [subRes, tplRes, histRes, cfgRes] = await Promise.all([
|
||||
fetch('/api/admin-contact-submissions'),
|
||||
fetch('/api/admin-contact-reply-templates'),
|
||||
fetch('/api/admin-contact-reply-history'),
|
||||
fetch('/api/admin-reply-config'),
|
||||
])
|
||||
if (!subRes.ok) throw new Error('submissions failed')
|
||||
const subData = await subRes.json() as { submissions?: ContactSubmission[] }
|
||||
setSubmissions(subData.submissions ?? [])
|
||||
setLoadStatus('ready')
|
||||
if (tplRes.ok) {
|
||||
const d = await tplRes.json() as { templates?: ReplyTemplate[] }
|
||||
setTemplates(d.templates ?? [])
|
||||
}
|
||||
if (histRes.ok) {
|
||||
const d = await histRes.json() as { items?: ReplyHistoryItem[] }
|
||||
setHistory(d.items ?? [])
|
||||
}
|
||||
if (cfgRes.ok) setConfig(await cfgRes.json() as ReplyConfig)
|
||||
} catch {
|
||||
setLoadStatus('error')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadAll() }, [loadAll])
|
||||
|
||||
// 30s poll for new messages
|
||||
useEffect(() => {
|
||||
const id = 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 = data.submissions ?? []
|
||||
setSubmissions(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 {}
|
||||
}, 30_000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// ── Derived ──
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const inMailbox = submissions.filter(s => mailbox === 'archived' ? s.archived === true : s.archived !== true)
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return inMailbox
|
||||
return inMailbox.filter(s => {
|
||||
const subj = extractSubject(s.message).toLowerCase()
|
||||
return (
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.email.toLowerCase().includes(q) ||
|
||||
subj.includes(q) ||
|
||||
s.message.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [submissions, mailbox, search])
|
||||
|
||||
const selected = filtered.find(s => s.id === selectedId) ?? null
|
||||
|
||||
const inboxCount = submissions.filter(s => s.archived !== true).length
|
||||
const unreadCount = submissions.filter(s => s.archived !== true && !localReadIds.has(s.id)).length
|
||||
|
||||
// Auto-select first on mailbox switch or after actions
|
||||
useEffect(() => {
|
||||
if (filtered.length === 0) { setSelectedId(null); return }
|
||||
if (!selectedId || !filtered.some(s => s.id === selectedId)) {
|
||||
setSelectedId(filtered[0].id)
|
||||
}
|
||||
}, [filtered, selectedId])
|
||||
|
||||
// Mark as read when selected
|
||||
useEffect(() => {
|
||||
if (!selectedId) return
|
||||
if (localReadIds.has(selectedId)) return
|
||||
persistReadId(selectedId)
|
||||
setLocalReadIds(prev => { const n = new Set(prev); n.add(selectedId); return n })
|
||||
}, [selectedId, localReadIds])
|
||||
|
||||
// Auto-scroll selected into view in list
|
||||
useEffect(() => {
|
||||
if (!selectedId || !listRef.current) return
|
||||
const el = listRef.current.querySelector(`[data-id="${selectedId}"]`) as HTMLElement | null
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
}, [selectedId])
|
||||
|
||||
// Focus compose textarea when reply opens
|
||||
useEffect(() => {
|
||||
if (replyDraft) setTimeout(() => composeRef.current?.focus(), 50)
|
||||
}, [replyDraft])
|
||||
|
||||
// ── Keyboard nav ──
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const tag = (e.target as Element).tagName.toLowerCase()
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return
|
||||
const idx = filtered.findIndex(s => s.id === selectedId)
|
||||
if (e.key === 'ArrowDown' || e.key === 'j') {
|
||||
e.preventDefault()
|
||||
if (idx < filtered.length - 1) setSelectedId(filtered[idx + 1].id)
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'k') {
|
||||
e.preventDefault()
|
||||
if (idx > 0) setSelectedId(filtered[idx - 1].id)
|
||||
} else if (e.key === 'r' && selected && !replyDraft) {
|
||||
openReply(selected)
|
||||
} else if (e.key === 'Escape' && replyDraft) {
|
||||
setReplyDraft(null)
|
||||
} else if (e.key === 'e' && selected) {
|
||||
handleArchive(selected.id, !(selected.archived === true))
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [filtered, selectedId, selected, replyDraft])
|
||||
|
||||
// ── Actions ──
|
||||
|
||||
async function handleArchive(id: string, archive: boolean) {
|
||||
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ archived: archive }),
|
||||
})
|
||||
if (!res.ok) { flash('Failed to update.'); return }
|
||||
setSubmissions(prev => prev.map(s => s.id === id ? { ...s, archived: archive } : s))
|
||||
flash(archive ? 'Archived.' : 'Moved to inbox.')
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Delete this message permanently?')) return
|
||||
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
if (!res.ok) { flash('Failed to delete.'); return }
|
||||
setSubmissions(prev => prev.filter(s => s.id !== id))
|
||||
flash('Message deleted.')
|
||||
}
|
||||
|
||||
function flash(msg: string) {
|
||||
setActionMsg(msg)
|
||||
setTimeout(() => setActionMsg(''), 3000)
|
||||
}
|
||||
|
||||
function openReply(submission: ContactSubmission) {
|
||||
const firstName = submission.name?.trim().split(/\s+/)[0] || 'there'
|
||||
const subject = extractSubject(submission.message)
|
||||
const inboundTo = submission.inboundTo ?? ''
|
||||
const defaultFrom = inboundTo.includes('nate@') ? REPLY_FROM_OPTIONS[1].value : REPLY_FROM_OPTIONS[0].value
|
||||
setReplyDraft({
|
||||
submissionId: submission.id,
|
||||
recipientName: submission.name,
|
||||
recipientEmail: submission.email,
|
||||
subject: subject ? `Re: ${subject}` : 'Re: Your message',
|
||||
message: '',
|
||||
fromAddress: defaultFrom,
|
||||
})
|
||||
setReplyMsg('')
|
||||
flash(`Composing reply to ${firstName}…`)
|
||||
}
|
||||
|
||||
function openCompose() {
|
||||
setSelectedId(null)
|
||||
setReplyDraft({
|
||||
submissionId: null,
|
||||
recipientName: '',
|
||||
recipientEmail: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
fromAddress: REPLY_FROM_OPTIONS[0].value,
|
||||
})
|
||||
setReplyMsg('')
|
||||
}
|
||||
|
||||
function applyTemplate(templateId: string) {
|
||||
if (!replyDraft) return
|
||||
const tpl = templates.find(t => t.id === templateId)
|
||||
if (!tpl) return
|
||||
setReplyDraft({ ...replyDraft, message: tpl.message })
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!replyDraft) return
|
||||
setReplySending(true)
|
||||
setReplyMsg('')
|
||||
|
||||
try {
|
||||
if (replyDraft.submissionId) {
|
||||
// Reply to existing submission
|
||||
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(replyDraft.submissionId)}/reply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subject: replyDraft.subject,
|
||||
message: replyDraft.message,
|
||||
fromAddress: replyDraft.fromAddress,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({})) as { message?: string }
|
||||
throw new Error(d.message ?? 'Failed to send.')
|
||||
}
|
||||
} else {
|
||||
// Compose new (no existing submission)
|
||||
const res = await fetch('/api/admin-email/compose', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
to: replyDraft.recipientEmail,
|
||||
toName: replyDraft.recipientName,
|
||||
subject: replyDraft.subject,
|
||||
message: replyDraft.message,
|
||||
fromAddress: replyDraft.fromAddress,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({})) as { message?: string }
|
||||
throw new Error(d.message ?? 'Failed to send.')
|
||||
}
|
||||
}
|
||||
|
||||
const sentFrom = replyDraft.fromAddress.match(/<([^>]+)>/)?.[1] ?? replyDraft.fromAddress
|
||||
setReplyMsg(`Sent to ${replyDraft.recipientEmail} from ${sentFrom}.`)
|
||||
setReplyDraft(null)
|
||||
|
||||
// Refresh history
|
||||
fetch('/api/admin-contact-reply-history')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d) setHistory((d as { items?: ReplyHistoryItem[] }).items ?? []) })
|
||||
.catch(() => {})
|
||||
} catch (err) {
|
||||
setReplyMsg(err instanceof Error ? err.message : 'Failed to send.')
|
||||
} finally {
|
||||
setReplySending(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveTemplates() {
|
||||
setTemplateStatus('saving')
|
||||
try {
|
||||
const res = await fetch('/api/admin-contact-reply-templates', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ templates }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to save')
|
||||
setTemplateStatus('saved')
|
||||
} catch {
|
||||
setTemplateStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ──
|
||||
|
||||
return (
|
||||
<div className="em-app">
|
||||
{/* Header */}
|
||||
<header className="em-header">
|
||||
<div className="em-header-left">
|
||||
<span className="em-header-brand">✉ Email Center</span>
|
||||
{unreadCount > 0 && <span className="em-badge em-badge--alert">{unreadCount} new</span>}
|
||||
</div>
|
||||
<div className="em-header-actions">
|
||||
<button type="button" className="em-btn em-btn--primary" onClick={openCompose}>
|
||||
Compose
|
||||
</button>
|
||||
<Link to="/admin" className="em-btn em-btn--ghost">← Admin</Link>
|
||||
<button type="button" className="em-btn em-btn--ghost" onClick={onLogout}>Log Out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<div className="em-body">
|
||||
{/* Sidebar */}
|
||||
<aside className="em-sidebar">
|
||||
{/* Search */}
|
||||
<div className="em-search-wrap">
|
||||
<input
|
||||
className="em-search"
|
||||
type="search"
|
||||
placeholder="Search…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mailbox tabs */}
|
||||
<div className="em-mailbox-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`em-mailbox-tab${mailbox === 'inbox' ? ' em-mailbox-tab--active' : ''}`}
|
||||
onClick={() => { setMailbox('inbox'); setSearch('') }}
|
||||
>
|
||||
Inbox
|
||||
{inboxCount > 0 && (
|
||||
<span className={`em-tab-badge${unreadCount > 0 ? ' em-tab-badge--unread' : ''}`}>
|
||||
{inboxCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`em-mailbox-tab${mailbox === 'archived' ? ' em-mailbox-tab--active' : ''}`}
|
||||
onClick={() => { setMailbox('archived'); setSearch('') }}
|
||||
>
|
||||
Archived
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<section className="em-list" ref={listRef} aria-label="Messages">
|
||||
{loadStatus === 'loading' && <p className="em-list-empty">Loading…</p>}
|
||||
{loadStatus === 'error' && <p className="em-list-empty">Could not load messages.</p>}
|
||||
{loadStatus === 'ready' && filtered.length === 0 && (
|
||||
<p className="em-list-empty">{search ? 'No results.' : 'No messages here.'}</p>
|
||||
)}
|
||||
{filtered.map(item => {
|
||||
const subject = item.source === 'inbound-email' ? extractSubject(item.message) : ''
|
||||
const preview = item.source === 'inbound-email' ? extractBodyPreview(item.message) : item.message
|
||||
const isUnread = !localReadIds.has(item.id)
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
data-id={item.id}
|
||||
type="button"
|
||||
className={`em-list-item${selectedId === item.id ? ' em-list-item--active' : ''}${isUnread ? ' em-list-item--unread' : ''}`}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
>
|
||||
{isUnread && <span className="em-unread-dot" aria-label="Unread" />}
|
||||
<div className="em-list-item-head">
|
||||
<strong className="em-list-item-name">{item.name}</strong>
|
||||
<span className="em-list-item-date">{formatShortDate(item.submittedAt)}</span>
|
||||
</div>
|
||||
{subject && <div className="em-list-item-subject">{subject}</div>}
|
||||
<div className="em-list-item-preview">{preview}</div>
|
||||
{item.source === 'inbound-email' && (
|
||||
<span className="em-source-badge em-source-badge--inbound">
|
||||
{item.inboundTo?.includes('nate@') ? 'nate@' : 'hello@'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
{/* Detail pane */}
|
||||
<main className="em-detail">
|
||||
{replyDraft && !replyDraft.submissionId ? (
|
||||
/* ── Compose New ── */
|
||||
<div className="em-compose-new">
|
||||
<div className="em-detail-header">
|
||||
<h2 className="em-detail-subject">New Message</h2>
|
||||
</div>
|
||||
<div className="em-compose-fields">
|
||||
<div className="em-compose-row">
|
||||
<label className="em-compose-label">To</label>
|
||||
<input
|
||||
className="em-compose-input"
|
||||
type="email"
|
||||
placeholder="recipient@example.com"
|
||||
value={replyDraft.recipientEmail}
|
||||
onChange={e => setReplyDraft({ ...replyDraft, recipientEmail: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="em-compose-row">
|
||||
<label className="em-compose-label">Name</label>
|
||||
<input
|
||||
className="em-compose-input"
|
||||
type="text"
|
||||
placeholder="Recipient name (optional)"
|
||||
value={replyDraft.recipientName}
|
||||
onChange={e => setReplyDraft({ ...replyDraft, recipientName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="em-compose-row">
|
||||
<label className="em-compose-label">From</label>
|
||||
<select
|
||||
className="em-compose-select"
|
||||
value={replyDraft.fromAddress}
|
||||
onChange={e => setReplyDraft({ ...replyDraft, fromAddress: e.target.value })}
|
||||
>
|
||||
{REPLY_FROM_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="em-compose-row">
|
||||
<label className="em-compose-label">Subject</label>
|
||||
<input
|
||||
className="em-compose-input"
|
||||
type="text"
|
||||
value={replyDraft.subject}
|
||||
onChange={e => setReplyDraft({ ...replyDraft, subject: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{templates.length > 0 && (
|
||||
<div className="em-compose-row">
|
||||
<label className="em-compose-label">Template</label>
|
||||
<select className="em-compose-select" defaultValue="" onChange={e => applyTemplate(e.target.value)}>
|
||||
<option value="">Choose a template…</option>
|
||||
{templates.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={composeRef}
|
||||
className="em-compose-body"
|
||||
rows={12}
|
||||
placeholder="Type your message…"
|
||||
value={replyDraft.message}
|
||||
onChange={e => setReplyDraft({ ...replyDraft, message: e.target.value })}
|
||||
/>
|
||||
<div className="em-compose-signature">
|
||||
Grace and peace, · Verse by Verse with Nate
|
||||
<span className="em-compose-signature-note">auto-appended</span>
|
||||
</div>
|
||||
<div className="em-compose-actions">
|
||||
<button type="button" className="em-btn em-btn--primary" onClick={handleSend} disabled={replySending}>
|
||||
{replySending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
<button type="button" className="em-btn em-btn--ghost" onClick={() => setReplyDraft(null)}>Discard</button>
|
||||
</div>
|
||||
{replyMsg && <p className="em-status-msg">{replyMsg}</p>}
|
||||
</div>
|
||||
</div>
|
||||
) : !selected ? (
|
||||
/* ── Empty state ── */
|
||||
<div className="em-empty-state">
|
||||
<div className="em-empty-icon">✉</div>
|
||||
<p>Select a message to read it.</p>
|
||||
<p className="em-empty-hint">↑ ↓ or j / k to navigate · r to reply · e to archive</p>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Message detail ── */
|
||||
<>
|
||||
<div className="em-detail-header">
|
||||
<div className="em-detail-meta-row">
|
||||
<div className="em-detail-from">
|
||||
<div className="em-detail-avatar">{(selected.name || '?')[0].toUpperCase()}</div>
|
||||
<div>
|
||||
<div className="em-detail-from-name">{selected.name}</div>
|
||||
<div className="em-detail-from-email">{selected.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="em-detail-meta-right">
|
||||
<div className="em-detail-date">{formatDate(selected.submittedAt)}</div>
|
||||
{selected.source === 'inbound-email' && selected.inboundTo && (
|
||||
<div className="em-detail-to">to: {selected.inboundTo}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="em-detail-subject-row">
|
||||
<h2 className="em-detail-subject">
|
||||
{selected.source === 'inbound-email'
|
||||
? extractSubject(selected.message) || selected.messageType
|
||||
: selected.messageType}
|
||||
</h2>
|
||||
<div className="em-detail-badges">
|
||||
{selected.source && (
|
||||
<span className={`em-source-badge em-source-badge--${selected.source}`}>
|
||||
{selected.source === 'inbound-email' ? 'email' : selected.source === 'contact-form' ? 'contact form' : selected.source}
|
||||
</span>
|
||||
)}
|
||||
{selected.subscribe && <span className="em-source-badge em-source-badge--subscribed">subscribed</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="em-detail-body">
|
||||
{selected.htmlBody ? (
|
||||
<iframe
|
||||
srcDoc={selected.htmlBody}
|
||||
sandbox="allow-same-origin"
|
||||
className="em-detail-iframe"
|
||||
onLoad={e => {
|
||||
const f = e.currentTarget
|
||||
const h = f.contentDocument?.documentElement?.scrollHeight
|
||||
if (h) f.style.height = `${h + 24}px`
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<pre className="em-detail-plain">{
|
||||
selected.source === 'inbound-email'
|
||||
? selected.message.replace(/^Subject:\s*.+\n+/m, '').trim()
|
||||
: selected.message
|
||||
}</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="em-detail-footer">
|
||||
<div className="em-detail-actions">
|
||||
<button type="button" className="em-btn em-btn--primary" onClick={() => openReply(selected)}>
|
||||
Reply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="em-btn em-btn--secondary"
|
||||
onClick={() => handleArchive(selected.id, !(selected.archived === true))}
|
||||
>
|
||||
{selected.archived ? 'Move to Inbox' : 'Archive'}
|
||||
</button>
|
||||
<button type="button" className="em-btn em-btn--danger" onClick={() => handleDelete(selected.id)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{actionMsg && !replyDraft && <p className="em-status-msg">{actionMsg}</p>}
|
||||
</div>
|
||||
|
||||
{/* Inline reply composer */}
|
||||
{replyDraft && replyDraft.submissionId === selected.id && (
|
||||
<div className="em-reply-composer">
|
||||
<div className="em-compose-header">
|
||||
<span className="em-compose-header-label">Reply to {replyDraft.recipientName}</span>
|
||||
<button type="button" className="em-compose-close" onClick={() => setReplyDraft(null)} aria-label="Close">×</button>
|
||||
</div>
|
||||
<div className="em-compose-fields">
|
||||
<div className="em-compose-row em-compose-row--inline">
|
||||
<label className="em-compose-label">From</label>
|
||||
<select
|
||||
className="em-compose-select"
|
||||
value={replyDraft.fromAddress}
|
||||
onChange={e => setReplyDraft({ ...replyDraft, fromAddress: e.target.value })}
|
||||
>
|
||||
{REPLY_FROM_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
{templates.length > 0 && (
|
||||
<>
|
||||
<label className="em-compose-label">Template</label>
|
||||
<select className="em-compose-select" defaultValue="" onChange={e => applyTemplate(e.target.value)}>
|
||||
<option value="">Choose…</option>
|
||||
{templates.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="em-compose-row">
|
||||
<label className="em-compose-label">Subject</label>
|
||||
<input
|
||||
className="em-compose-input"
|
||||
type="text"
|
||||
value={replyDraft.subject}
|
||||
onChange={e => setReplyDraft({ ...replyDraft, subject: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
ref={composeRef}
|
||||
className="em-compose-body"
|
||||
rows={7}
|
||||
placeholder="Type your reply…"
|
||||
value={replyDraft.message}
|
||||
onChange={e => setReplyDraft({ ...replyDraft, message: e.target.value })}
|
||||
/>
|
||||
<div className="em-compose-signature">
|
||||
Grace and peace, · Verse by Verse with Nate
|
||||
<span className="em-compose-signature-note">auto-appended</span>
|
||||
</div>
|
||||
<div className="em-compose-actions">
|
||||
<button type="button" className="em-btn em-btn--primary" onClick={handleSend} disabled={replySending}>
|
||||
{replySending ? 'Sending…' : 'Send Reply'}
|
||||
</button>
|
||||
<button type="button" className="em-btn em-btn--ghost" onClick={() => setReplyDraft(null)}>Cancel</button>
|
||||
{replyMsg && <span className="em-status-msg em-status-msg--inline">{replyMsg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Templates manager — slide-up drawer */}
|
||||
{showTemplatesMgr && (
|
||||
<div className="em-drawer-overlay" onClick={e => { if (e.target === e.currentTarget) setShowTemplatesMgr(false) }}>
|
||||
<div className="em-drawer">
|
||||
<div className="em-drawer-header">
|
||||
<h3>Reply Templates</h3>
|
||||
<button type="button" className="em-compose-close" onClick={() => setShowTemplatesMgr(false)}>×</button>
|
||||
</div>
|
||||
<div className="em-drawer-body">
|
||||
{templates.length === 0 && <p className="em-list-empty">No templates yet.</p>}
|
||||
{templates.map((tpl, i) => (
|
||||
<div key={tpl.id} className="em-template-card">
|
||||
<input
|
||||
className="em-compose-input"
|
||||
placeholder="Template label"
|
||||
value={tpl.label}
|
||||
onChange={e => setTemplates(prev => prev.map((t, j) => j === i ? { ...t, label: e.target.value } : t))}
|
||||
/>
|
||||
<input
|
||||
className="em-compose-input"
|
||||
placeholder="Subject (optional)"
|
||||
value={tpl.subject}
|
||||
onChange={e => setTemplates(prev => prev.map((t, j) => j === i ? { ...t, subject: e.target.value } : t))}
|
||||
/>
|
||||
<textarea
|
||||
className="em-compose-body"
|
||||
rows={4}
|
||||
placeholder="Message body"
|
||||
value={tpl.message}
|
||||
onChange={e => setTemplates(prev => prev.map((t, j) => j === i ? { ...t, message: e.target.value } : t))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="em-btn em-btn--danger em-btn--sm"
|
||||
onClick={() => setTemplates(prev => prev.filter((_, j) => j !== i))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="em-drawer-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="em-btn em-btn--ghost"
|
||||
onClick={() => setTemplates(prev => [...prev, { id: Date.now().toString(36), label: '', subject: '', message: '' }])}
|
||||
>
|
||||
Add Template
|
||||
</button>
|
||||
<button type="button" className="em-btn em-btn--primary" onClick={handleSaveTemplates} disabled={templateStatus === 'saving'}>
|
||||
{templateStatus === 'saving' ? 'Saving…' : templateStatus === 'saved' ? 'Saved ✓' : 'Save Templates'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sent history drawer */}
|
||||
{showHistory && (
|
||||
<div className="em-drawer-overlay" onClick={e => { if (e.target === e.currentTarget) setShowHistory(false) }}>
|
||||
<div className="em-drawer em-drawer--wide">
|
||||
<div className="em-drawer-header">
|
||||
<h3>Reply History</h3>
|
||||
<button type="button" className="em-compose-close" onClick={() => setShowHistory(false)}>×</button>
|
||||
</div>
|
||||
<div className="em-drawer-body">
|
||||
{history.length === 0 ? (
|
||||
<p className="em-list-empty">No replies sent yet.</p>
|
||||
) : (
|
||||
<div className="em-history-list">
|
||||
{history.map(item => (
|
||||
<div key={item.id} className="em-history-item">
|
||||
<div className="em-history-meta">
|
||||
<span className="em-history-date">{formatDate(item.sentAt)}</span>
|
||||
<span className="em-history-flow">from {item.fromEmail} → {item.toName} ({item.toEmail})</span>
|
||||
</div>
|
||||
<div className="em-history-subject">{item.subject}</div>
|
||||
<div className="em-history-preview">{item.preview}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer toolbar */}
|
||||
<div className="em-footer-toolbar">
|
||||
{config && !config.canSendReplies && (
|
||||
<span className="em-footer-warn">⚠ RESEND_API_KEY not configured — sending is disabled</span>
|
||||
)}
|
||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setShowTemplatesMgr(true)}>
|
||||
Templates
|
||||
</button>
|
||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setShowHistory(true)}>
|
||||
Sent History
|
||||
</button>
|
||||
<span className="em-footer-hint">↑ ↓ navigate · r reply · e archive · Esc close</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Auth shell ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EmailShell() {
|
||||
const [authStatus, setAuthStatus] = useState<'checking' | 'authenticated' | 'unauthenticated' | 'misconfigured'>('checking')
|
||||
const [password, setPassword] = useState('')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [totpRequired, setTotpRequired] = useState(false)
|
||||
const [pendingToken, setPendingToken] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin-auth/status')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject()))
|
||||
.then(data => {
|
||||
const d = data as { authenticated?: boolean; configured?: boolean }
|
||||
if (d.configured === false) { setAuthStatus('misconfigured'); return }
|
||||
setAuthStatus(d.authenticated ? 'authenticated' : 'unauthenticated')
|
||||
})
|
||||
.catch(() => setAuthStatus('unauthenticated'))
|
||||
}, [])
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const res = await fetch('/api/admin-auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
const data = await res.json().catch(() => ({})) as { ok?: boolean; totpRequired?: boolean; pendingToken?: string; message?: string }
|
||||
if (!res.ok) { setErrorMsg(data.message ?? 'Login failed.'); setSubmitting(false); return }
|
||||
if (data.totpRequired && data.pendingToken) {
|
||||
setPendingToken(data.pendingToken); setTotpRequired(true); setPassword(''); setSubmitting(false); return
|
||||
}
|
||||
setAuthStatus('authenticated'); setPassword('')
|
||||
} catch {
|
||||
setErrorMsg('Login failed.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotpVerify(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const res = await fetch('/api/admin-auth/totp-verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pendingToken, code: totpCode }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({})) as { message?: string }
|
||||
setErrorMsg(d.message ?? 'Invalid code.'); setSubmitting(false); return
|
||||
}
|
||||
setAuthStatus('authenticated'); setTotpCode('')
|
||||
} catch {
|
||||
setErrorMsg('Verification failed.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try { await fetch('/api/admin-auth/logout', { method: 'POST' }) } finally {
|
||||
setAuthStatus('unauthenticated'); setTotpRequired(false); setPendingToken('')
|
||||
}
|
||||
}
|
||||
|
||||
if (authStatus === 'authenticated') return <EmailClient onLogout={handleLogout} />
|
||||
|
||||
return (
|
||||
<main className="admin-auth-page" aria-label="Email sign in">
|
||||
<div className="admin-auth-card">
|
||||
<p className="eyebrow">Email Center</p>
|
||||
<h1>{authStatus === 'misconfigured' ? 'Not Configured' : 'Sign In'}</h1>
|
||||
{authStatus === 'checking' && <p className="admin-auth-note">Checking session…</p>}
|
||||
{authStatus === 'misconfigured' && (
|
||||
<p className="admin-auth-note">Set the ADMIN_PASSWORD environment variable to enable access.</p>
|
||||
)}
|
||||
{authStatus === 'unauthenticated' && !totpRequired && (
|
||||
<form className="admin-auth-form" onSubmit={handleLogin}>
|
||||
<label>Password
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" required />
|
||||
</label>
|
||||
{errorMsg && <p className="admin-auth-error">{errorMsg}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>{submitting ? 'Signing In…' : 'Sign In'}</button>
|
||||
<Link to="/" className="btn-secondary">Back to Site</Link>
|
||||
</form>
|
||||
)}
|
||||
{authStatus === 'unauthenticated' && totpRequired && (
|
||||
<form className="admin-auth-form" onSubmit={handleTotpVerify}>
|
||||
<p className="admin-auth-note">Enter the 6-digit code from your authenticator app.</p>
|
||||
<label>Code
|
||||
<input type="text" inputMode="numeric" value={totpCode} onChange={e => setTotpCode(e.target.value)} autoComplete="one-time-code" placeholder="000000" autoFocus required />
|
||||
</label>
|
||||
{errorMsg && <p className="admin-auth-error">{errorMsg}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>{submitting ? 'Verifying…' : 'Verify'}</button>
|
||||
<button type="button" className="btn-secondary" onClick={() => { setTotpRequired(false); setPendingToken(''); setTotpCode(''); setErrorMsg('') }}>Back</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user