Add a newsletter archive so past issues can be shared and found; v2.3.0
Publish Container / build (push) Successful in 20m51s
Publish Container / build (push) Successful in 20m51s
Web-hosted copies of episode newsletters, published from Resend broadcasts tagged with the "New Episode" topic. Gives Facebook (and anyone else) a proper link to share instead of raw HTML email, and makes issues findable via site search and Google. - Admin "Newsletter Archive" tab lists sent Resend broadcasts, flags ones tagged "New Episode", and publishes them with one click (pulls the HTML straight from Resend's API) - Public /newsletter archive index and /newsletter/:slug issue pages, server-rendered with per-issue Open Graph tags for social previews - Unsubscribe merge-tag stripped from the web copy; archive + subscribe links appended - Issues added to sitemap.xml and the on-site search index Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
60b23b0fba
commit
fe4c09f8a3
11 files changed
+516
-4
No files matched your search
+139
-1
@@ -688,7 +688,7 @@ type AdminView =
|
||||
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||||
| 'inbox'
|
||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
|
||||
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast' | 'newsletter-archive'
|
||||
| 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'webhooks'
|
||||
|
||||
interface AdminSectionLink {
|
||||
@@ -761,6 +761,7 @@ const ADMIN_NAV: NavGroup[] = [
|
||||
{ kind: 'leaf', view: 'study-comments', label: 'Study Comments' },
|
||||
{ kind: 'leaf', view: 'subscribers', label: 'Subscribers' },
|
||||
{ kind: 'leaf', view: 'broadcast', label: 'Broadcast Email' },
|
||||
{ kind: 'leaf', view: 'newsletter-archive', label: 'Newsletter Archive' },
|
||||
{ kind: 'leaf', view: 'study-users', label: 'Study Users' },
|
||||
],
|
||||
},
|
||||
@@ -1660,6 +1661,17 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [broadcastMsg, setBroadcastMsg] = useState('')
|
||||
const [broadcastConfirm, setBroadcastConfirm] = useState(false)
|
||||
|
||||
// Newsletter archive state
|
||||
interface ResendBroadcastRow { id: string; name: string; subject: string; sentAt: string | null; topicId: string | null; matchesNewEpisodeTopic: boolean; alreadyPublished: boolean }
|
||||
interface NewsletterIssueAdmin { id: string; resendBroadcastId: string; slug: string; title: string; subject: string; description: string; sentAt: string | null; publishedAt: string; public: boolean }
|
||||
const [nlBroadcasts, setNlBroadcasts] = useState<ResendBroadcastRow[]>([])
|
||||
const [nlIssues, setNlIssues] = useState<NewsletterIssueAdmin[]>([])
|
||||
const [nlLoaded, setNlLoaded] = useState(false)
|
||||
const [nlLoading, setNlLoading] = useState(false)
|
||||
const [nlTopicFound, setNlTopicFound] = useState(true)
|
||||
const [nlMsg, setNlMsg] = useState('')
|
||||
const [nlPublishingId, setNlPublishingId] = useState<string | null>(null)
|
||||
|
||||
const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
const [manualQuestion, setManualQuestion] = useState({
|
||||
firstName: '',
|
||||
@@ -2923,6 +2935,65 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
finally { setQrLoading(false) }
|
||||
}
|
||||
|
||||
async function loadNewsletterAdmin() {
|
||||
setNlLoading(true)
|
||||
setNlMsg('')
|
||||
try {
|
||||
const [broadcastsRes, issuesRes] = await Promise.all([
|
||||
fetch('/api/admin/newsletter/resend-broadcasts'),
|
||||
fetch('/api/admin/newsletter/issues'),
|
||||
])
|
||||
const broadcastsData = await broadcastsRes.json()
|
||||
const issuesData = await issuesRes.json()
|
||||
if (!broadcastsRes.ok) { setNlMsg(broadcastsData.message || 'Failed to load Resend broadcasts.') }
|
||||
setNlBroadcasts(Array.isArray(broadcastsData.broadcasts) ? broadcastsData.broadcasts : [])
|
||||
setNlTopicFound(broadcastsData.newEpisodeTopicFound !== false)
|
||||
setNlIssues(Array.isArray(issuesData.issues) ? issuesData.issues : [])
|
||||
setNlLoaded(true)
|
||||
} catch { setNlMsg('Network error loading newsletter data.') }
|
||||
finally { setNlLoading(false) }
|
||||
}
|
||||
|
||||
async function publishNewsletterIssue(broadcast: ResendBroadcastRow) {
|
||||
setNlPublishingId(broadcast.id)
|
||||
setNlMsg('')
|
||||
try {
|
||||
const res = await fetch('/api/admin/newsletter/issues', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ broadcastId: broadcast.id }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { setNlMsg(data.message || 'Failed to publish issue.'); return }
|
||||
setNlIssues(prev => [data.issue, ...prev])
|
||||
setNlBroadcasts(prev => prev.map(b => b.id === broadcast.id ? { ...b, alreadyPublished: true } : b))
|
||||
} catch { setNlMsg('Network error. Try again.') }
|
||||
finally { setNlPublishingId(null) }
|
||||
}
|
||||
|
||||
async function toggleNewsletterIssuePublic(issue: NewsletterIssueAdmin) {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/newsletter/issues/${issue.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ public: !issue.public }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) setNlIssues(prev => prev.map(i => i.id === issue.id ? data.issue : i))
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function deleteNewsletterIssue(issue: NewsletterIssueAdmin) {
|
||||
if (!window.confirm(`Remove "${issue.title}" from the newsletter archive? This does not affect the sent email.`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/admin/newsletter/issues/${issue.id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
setNlIssues(prev => prev.filter(i => i.id !== issue.id))
|
||||
setNlBroadcasts(prev => prev.map(b => b.id === issue.resendBroadcastId ? { ...b, alreadyPublished: false } : b))
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function handleCreateQrCode() {
|
||||
setQrMsg('')
|
||||
try {
|
||||
@@ -6321,6 +6392,73 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* ── NEWSLETTER ARCHIVE ── */}
|
||||
{adminView === 'newsletter-archive' && (() => {
|
||||
if (!nlLoaded && !nlLoading) loadNewsletterAdmin()
|
||||
const unpublished = nlBroadcasts.filter(b => !b.alreadyPublished && b.matchesNewEpisodeTopic)
|
||||
return (
|
||||
<section className="admin-panel-section">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Newsletter Archive</h2>
|
||||
<p>Publish a web copy of each episode newsletter so it can be shared (e.g. on Facebook) and found via search. Only Resend broadcasts tagged with the <strong>New Episode</strong> topic show up here.</p>
|
||||
</div>
|
||||
|
||||
{!nlTopicFound && (
|
||||
<p className="admin-save-error" style={{ marginBottom: '1rem' }}>
|
||||
No "New Episode" topic found in Resend. Broadcasts won't be flagged for publishing until that topic exists and is applied when sending.
|
||||
</p>
|
||||
)}
|
||||
{nlMsg && <p className="admin-save-error" style={{ marginBottom: '1rem' }}>{nlMsg}</p>}
|
||||
|
||||
<h3 style={{ marginTop: 0 }}>Published Issues ({nlIssues.length})</h3>
|
||||
{nlLoading && nlIssues.length === 0 ? <p className="admin-stats-note">Loading…</p> : null}
|
||||
{!nlLoading && nlIssues.length === 0 && <p className="admin-stats-note">No issues published yet.</p>}
|
||||
{nlIssues.map(issue => (
|
||||
<div key={issue.id} className="admin-list-row" style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap', padding: '0.6rem 0', borderBottom: '1px solid var(--admin-border)' }}>
|
||||
<div style={{ flex: 1, minWidth: '200px' }}>
|
||||
<strong>{issue.title}</strong>
|
||||
<div style={{ fontSize: '0.8rem', opacity: 0.65 }}>
|
||||
/newsletter/{issue.slug} · {issue.sentAt ? new Date(issue.sentAt).toLocaleDateString() : 'no send date'} · {issue.public ? 'Public' : 'Unlisted'}
|
||||
</div>
|
||||
</div>
|
||||
<a href={`/newsletter/${issue.slug}`} target="_blank" rel="noreferrer" className="btn-admin-reset">View</a>
|
||||
<button type="button" className="btn-admin-reset" onClick={() => toggleNewsletterIssuePublic(issue)}>
|
||||
{issue.public ? 'Unlist' : 'Make Public'}
|
||||
</button>
|
||||
<button type="button" className="btn-admin-reset" onClick={() => deleteNewsletterIssue(issue)}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h3>Ready to Publish ({unpublished.length})</h3>
|
||||
<p className="admin-stats-note">Sent broadcasts tagged "New Episode" that aren't on the site yet.</p>
|
||||
{nlLoading && unpublished.length === 0 ? <p className="admin-stats-note">Loading from Resend…</p> : null}
|
||||
{!nlLoading && unpublished.length === 0 && <p className="admin-stats-note">Nothing new to publish.</p>}
|
||||
{unpublished.map(broadcast => (
|
||||
<div key={broadcast.id} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap', padding: '0.6rem 0', borderBottom: '1px solid var(--admin-border)' }}>
|
||||
<div style={{ flex: 1, minWidth: '200px' }}>
|
||||
<strong>{broadcast.subject || broadcast.name}</strong>
|
||||
<div style={{ fontSize: '0.8rem', opacity: 0.65 }}>
|
||||
{broadcast.sentAt ? new Date(broadcast.sentAt).toLocaleDateString() : 'no send date'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-apply"
|
||||
disabled={nlPublishingId === broadcast.id}
|
||||
onClick={() => publishNewsletterIssue(broadcast)}
|
||||
>
|
||||
{nlPublishingId === broadcast.id ? 'Publishing…' : 'Publish'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="button" className="btn-admin-reset" style={{ marginTop: '1rem' }} onClick={loadNewsletterAdmin} disabled={nlLoading}>
|
||||
{nlLoading ? 'Refreshing…' : 'Refresh from Resend'}
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* ── WEBHOOKS ── */}
|
||||
{adminView === 'webhooks' && (() => {
|
||||
if (!webhooksLoaded) loadWebhooksAdmin()
|
||||
|
||||
Reference in new issue
Block a user