v1.2.0: Broadcast emails, webhook admin UI, dynamic sitemap, scripture index
Publish Container / docker (push) Successful in 14m11s
Publish Container / docker (push) Successful in 14m11s
- Admin > Audience > Broadcast Email: compose and send one-off HTML emails
to all subscribers via Resend batch API; {{name}} personalization; confirm step
- Admin > Configure > Webhooks: CRUD UI for managing POST event webhooks;
backend GET/POST/DELETE /api/admin-webhooks routes wired to existing state.webhooks
- Sitemap: dynamically appends released study lesson URLs (/study/:slug/:id)
and episode highlight URLs (/episodes/:id) to /sitemap.xml
- Scripture Index: public /scripture page listing every released lesson grouped
by study with passage reference links; served by /api/public/scripture-index
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
0bf2d92b1f
commit
63d4f9e316
7 files changed
+348
-3
No files matched your search
+162
-2
@@ -612,8 +612,8 @@ type AdminView =
|
||||
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
|
||||
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||
| 'subscribers' | 'study-users' | 'email-templates'
|
||||
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
|
||||
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
|
||||
| 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'webhooks'
|
||||
|
||||
interface AdminSectionLink {
|
||||
id: string
|
||||
@@ -675,6 +675,7 @@ const ADMIN_NAV: NavGroup[] = [
|
||||
{ kind: 'leaf', view: 'questions', label: 'Questions' },
|
||||
{ kind: 'leaf', view: 'study-comments', label: 'Study Comments' },
|
||||
{ kind: 'leaf', view: 'subscribers', label: 'Subscribers' },
|
||||
{ kind: 'leaf', view: 'broadcast', label: 'Broadcast Email' },
|
||||
{ kind: 'leaf', view: 'study-users', label: 'Study Users' },
|
||||
],
|
||||
},
|
||||
@@ -692,6 +693,7 @@ const ADMIN_NAV: NavGroup[] = [
|
||||
{ kind: 'leaf', view: 'global', label: 'Footer & Global' },
|
||||
{ kind: 'leaf', view: 'seo', label: 'SEO & Redirects' },
|
||||
{ kind: 'leaf', view: 'email-templates', label: 'Email Templates' },
|
||||
{ kind: 'leaf', view: 'webhooks', label: 'Webhooks' },
|
||||
{ kind: 'leaf', view: 'legal', label: 'Legal Pages' },
|
||||
{ kind: 'leaf', view: 'security', label: 'Security' },
|
||||
{ kind: 'leaf', view: 'brand', label: 'Brand Kit' },
|
||||
@@ -1114,6 +1116,22 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [selectedHighlightId, setSelectedHighlightId] = useState<string | null>(null)
|
||||
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null)
|
||||
|
||||
// Webhooks state
|
||||
interface WebhookEntry { id: string; url: string; events: string[]; createdAt: string }
|
||||
const [webhooks, setWebhooks] = useState<WebhookEntry[]>([])
|
||||
const [webhooksLoaded, setWebhooksLoaded] = useState(false)
|
||||
const [webhookUrl, setWebhookUrl] = useState('')
|
||||
const [webhookEvents, setWebhookEvents] = useState('*')
|
||||
const [webhookMsg, setWebhookMsg] = useState('')
|
||||
|
||||
// Broadcast state
|
||||
const [broadcastSubject, setBroadcastSubject] = useState('')
|
||||
const [broadcastBody, setBroadcastBody] = useState('')
|
||||
const [broadcastSending, setBroadcastSending] = useState(false)
|
||||
const [broadcastResult, setBroadcastResult] = useState<{ sent: number; failed: number; total: number } | null>(null)
|
||||
const [broadcastMsg, setBroadcastMsg] = useState('')
|
||||
const [broadcastConfirm, setBroadcastConfirm] = useState(false)
|
||||
|
||||
const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
const [manualQuestion, setManualQuestion] = useState({
|
||||
firstName: '',
|
||||
@@ -2320,6 +2338,14 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWebhooksAdmin() {
|
||||
try {
|
||||
const res = await fetch('/api/admin-webhooks')
|
||||
const data = await res.json()
|
||||
if (Array.isArray(data.webhooks)) { setWebhooks(data.webhooks); setWebhooksLoaded(true) }
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function loadQrCodes() {
|
||||
setQrLoading(true)
|
||||
setQrMsg('')
|
||||
@@ -5108,6 +5134,140 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── BROADCAST EMAIL ── */}
|
||||
{adminView === 'broadcast' && (() => {
|
||||
const recipientCount = (() => {
|
||||
const seen = new Set<string>()
|
||||
return subscribers.filter(s => s.email && !seen.has(s.email) && !!seen.add(s.email)).length
|
||||
})()
|
||||
async function sendBroadcast() {
|
||||
if (!broadcastSubject.trim() || !broadcastBody.trim()) {
|
||||
setBroadcastMsg('Subject and body are required.'); return
|
||||
}
|
||||
setBroadcastSending(true); setBroadcastMsg(''); setBroadcastResult(null)
|
||||
try {
|
||||
const r = await fetch('/api/admin-broadcast', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subject: broadcastSubject, html: broadcastBody }),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setBroadcastMsg(d.message || 'Send failed.') } else {
|
||||
setBroadcastResult(d); setBroadcastConfirm(false)
|
||||
setBroadcastMsg(`Sent to ${d.sent} subscriber${d.sent !== 1 ? 's' : ''}${d.failed > 0 ? ` (${d.failed} failed)` : ''}.`)
|
||||
}
|
||||
} catch { setBroadcastMsg('Network error. Try again.') }
|
||||
finally { setBroadcastSending(false) }
|
||||
}
|
||||
return (
|
||||
<section className="admin-panel-section">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Broadcast Email</h2>
|
||||
<p>Compose and send a one-off email to all {subscribers.length} subscribers.</p>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="bc-subject">Subject</label>
|
||||
<input id="bc-subject" type="text" value={broadcastSubject} onChange={e => setBroadcastSubject(e.target.value)} placeholder="This week's episode + new study lesson" />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="bc-body">Body <span style={{ fontWeight: 400, opacity: 0.6, fontSize: '0.8em' }}>(HTML — use {'{{name}}'} for personalization)</span></label>
|
||||
<textarea id="bc-body" rows={14} value={broadcastBody} onChange={e => setBroadcastBody(e.target.value)} placeholder={'<p>Hi {{name}},</p>\n<p>This week we covered...</p>'} style={{ fontFamily: 'monospace', fontSize: '0.85rem' }} />
|
||||
</div>
|
||||
{broadcastBody.trim() && (
|
||||
<details style={{ marginBottom: '1rem' }}>
|
||||
<summary style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.7 }}>Preview rendered HTML</summary>
|
||||
<div style={{ marginTop: '0.75rem', padding: '1rem', background: 'var(--admin-card-bg)', borderRadius: '6px', border: '1px solid var(--admin-border)', fontSize: '0.9rem' }}
|
||||
dangerouslySetInnerHTML={{ __html: broadcastBody.replace(/\{\{name\}\}/g, 'Nate (preview)') }} />
|
||||
</details>
|
||||
)}
|
||||
{broadcastMsg && <p className={broadcastResult ? 'admin-save-ok' : 'admin-save-error'} style={{ marginBottom: '0.75rem' }}>{broadcastMsg}</p>}
|
||||
{!broadcastConfirm ? (
|
||||
<button type="button" className="btn-admin-apply" disabled={!broadcastSubject.trim() || !broadcastBody.trim()} onClick={() => setBroadcastConfirm(true)}>
|
||||
Send to {recipientCount} subscriber{recipientCount !== 1 ? 's' : ''}…
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<strong style={{ color: 'var(--brand-gold)' }}>Send "{broadcastSubject}" to {recipientCount} people?</strong>
|
||||
<button type="button" className="btn-admin-apply" disabled={broadcastSending} onClick={sendBroadcast}>
|
||||
{broadcastSending ? 'Sending…' : 'Confirm Send'}
|
||||
</button>
|
||||
<button type="button" className="btn-admin-reset" onClick={() => setBroadcastConfirm(false)}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* ── WEBHOOKS ── */}
|
||||
{adminView === 'webhooks' && (() => {
|
||||
if (!webhooksLoaded) loadWebhooksAdmin()
|
||||
const ALL_EVENTS = ['*', 'contact.submitted', 'subscriber.added', 'question.submitted', 'study.lesson.released', 'study.user.enrolled']
|
||||
async function addWebhook() {
|
||||
if (!webhookUrl.trim()) { setWebhookMsg('A URL is required.'); return }
|
||||
setWebhookMsg('')
|
||||
try {
|
||||
const r = await fetch('/api/admin-webhooks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: webhookUrl, events: webhookEvents.split(',').map(e => e.trim()).filter(Boolean) }),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setWebhookMsg(d.message || 'Failed to add webhook.') } else {
|
||||
setWebhooks(prev => [...prev, d.webhook])
|
||||
setWebhookUrl(''); setWebhookEvents('*')
|
||||
setWebhookMsg('Webhook added.')
|
||||
}
|
||||
} catch { setWebhookMsg('Network error.') }
|
||||
}
|
||||
async function deleteWebhook(id: string) {
|
||||
if (!confirm('Delete this webhook?')) return
|
||||
try {
|
||||
await fetch(`/api/admin-webhooks/${id}`, { method: 'DELETE' })
|
||||
setWebhooks(prev => prev.filter(w => w.id !== id))
|
||||
} catch { setWebhookMsg('Failed to delete.') }
|
||||
}
|
||||
return (
|
||||
<section className="admin-panel-section">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Webhooks</h2>
|
||||
<p>Send POST notifications to external URLs when site events fire.</p>
|
||||
</div>
|
||||
{webhooks.length > 0 ? (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
{webhooks.map(wh => (
|
||||
<div key={wh.id} className="study-lesson-row" style={{ alignItems: 'flex-start', padding: '0.75rem 0.5rem' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.82rem', wordBreak: 'break-all', marginBottom: '0.25rem' }}>{wh.url}</div>
|
||||
<div style={{ fontSize: '0.75rem', opacity: 0.6 }}>
|
||||
Events: <span style={{ fontFamily: 'monospace' }}>{(wh.events ?? ['*']).join(', ')}</span>
|
||||
{' · '}{new Date(wh.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" style={{ fontSize: '0.8rem', padding: '0.2rem 0.5rem', marginLeft: '0.5rem' }} onClick={() => deleteWebhook(wh.id)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="admin-stats-note" style={{ marginBottom: '1.5rem' }}>No webhooks configured.</p>
|
||||
)}
|
||||
<div className="admin-panel-head" style={{ marginTop: 0 }}>
|
||||
<h3 style={{ fontSize: '0.95rem' }}>Add Webhook</h3>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="wh-url">Endpoint URL (HTTPS)</label>
|
||||
<input id="wh-url" type="url" value={webhookUrl} onChange={e => setWebhookUrl(e.target.value)} placeholder="https://example.com/hook" />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="wh-events">Events <span style={{ fontWeight: 400, opacity: 0.6, fontSize: '0.8em' }}>(comma-separated; * = all)</span></label>
|
||||
<input id="wh-events" type="text" value={webhookEvents} onChange={e => setWebhookEvents(e.target.value)} placeholder="*" />
|
||||
<p className="admin-field-hint" style={{ marginTop: '0.3rem' }}>Available: {ALL_EVENTS.join(', ')}</p>
|
||||
</div>
|
||||
{webhookMsg && <p className="admin-save-ok" style={{ marginBottom: '0.5rem' }}>{webhookMsg}</p>}
|
||||
<button type="button" className="btn-admin-apply" onClick={addWebhook}>Add Webhook</button>
|
||||
</section>
|
||||
)
|
||||
})()}
|
||||
|
||||
</main>
|
||||
|
||||
{/* ── Live preview pane ── */}
|
||||
|
||||
Reference in new issue
Block a user