Add a newsletter archive so past issues can be shared and found; v2.3.0
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:
nmemmertandClaude Sonnet 5 committed 2026-09-28 12:17:13 -04:00
1 parent 60b23b0fba
commit fe4c09f8a3
11 files changed
+516 -4

No files matched your search

+139 -1
View File
@@ -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()
+68
View File
@@ -3022,6 +3022,73 @@ function QuestionsPage({ content }: { content: SiteContent }) {
)
}
interface NewsletterIssueSummary {
id: string
slug: string
title: string
subject: string
description: string
sentAt: string | null
publishedAt: string
}
function NewsletterArchivePage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Newsletter | ${siteTitle}`,
'Read past newsletter issues from Verse by Verse with Nate — episode notes, study excerpts, and Greek word studies.',
)
const [issues, setIssues] = useState<NewsletterIssueSummary[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/newsletter/issues')
.then(r => r.json())
.then(d => { if (Array.isArray(d.issues)) setIssues(d.issues) })
.catch(() => {})
.finally(() => setLoading(false))
}, [])
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Newsletter" title="Newsletter Archive" desc="Past issues of the Verse by Verse with Nate newsletter — episode notes, study excerpts, and Greek word studies." />
<section className="section-resources" aria-label="Newsletter archive">
<div className="section-inner">
{loading ? (
<p className="episode-seo-intro">Loading issues…</p>
) : issues.length === 0 ? (
<p className="episode-seo-intro">No newsletter issues published yet.</p>
) : (
<div className="resources-list">
{issues.map(issue => (
<a key={issue.id} href={`/newsletter/${issue.slug}`} className="resource-download-card">
<div className="resource-download-header">
<div className="resource-download-meta">
<span className="resource-link-label">{issue.title}</span>
{issue.description && <p className="resource-link-description">{issue.description}</p>}
{issue.sentAt && (
<span className="resource-link-tags">
{new Date(issue.sentAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
</span>
)}
<span className="resource-link-action">Read issue →</span>
</div>
</div>
</a>
))}
</div>
)}
</div>
</section>
<CustomBlocksSection content={content} page="newsletter" />
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
function PreviewPage() {
const [content, setContent] = useState<SiteContent | null>(null)
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
@@ -3239,6 +3306,7 @@ export default function App() {
<Route path="/about" element={<AboutPage content={content} />} />
<Route path="/contact" element={<ContactPage content={content} />} />
<Route path="/questions" element={<QuestionsPage content={content} />} />
<Route path="/newsletter" element={<NewsletterArchivePage content={content} />} />
<Route path="/scripture" element={<ScriptureIndexPage content={content} />} />
<Route path="/thanks" element={<ThankYouPage />} />
<Route path="/subscribe" element={<SubscribePage />} />
+5
View File
@@ -8,6 +8,7 @@ const TYPE_LABEL: Record<SearchResultType, string> = {
question: 'Q&A',
resource: 'Download',
series: 'Series',
newsletter: 'Newsletter',
}
const TYPE_ICON: Record<SearchResultType, string> = {
@@ -15,6 +16,7 @@ const TYPE_ICON: Record<SearchResultType, string> = {
question: '❓',
resource: '📄',
series: '📚',
newsletter: '✉️',
}
interface Props {
@@ -57,6 +59,9 @@ export function GlobalSearch({ query, setQuery, results }: Props) {
setQuery('')
if (result.href.startsWith('http')) {
window.open(result.href, '_blank', 'noreferrer')
} else if (result.type === 'newsletter') {
// Newsletter issues are server-rendered outside the SPA (for social-share OG tags), so use a full navigation.
window.location.href = result.href
} else {
navigate(result.href)
}
+21 -2
View File
@@ -2,7 +2,7 @@ import { useMemo, useState, useEffect, useRef } from 'react'
import Fuse from 'fuse.js'
import type { SiteContent } from '../content'
export type SearchResultType = 'episode' | 'question' | 'resource' | 'series'
export type SearchResultType = 'episode' | 'question' | 'resource' | 'series' | 'newsletter'
export interface SearchResult {
id: string
@@ -37,6 +37,7 @@ export function useGlobalSearch(content: SiteContent) {
const [questions, setQuestions] = useState<PublicQuestion[]>([])
const [episodeItems, setEpisodeItems] = useState<SearchResult[]>([])
const [nuggetsItems, setNuggetsItems] = useState<SearchResult[]>([])
const [newsletterItems, setNewsletterItems] = useState<SearchResult[]>([])
const [scriptResults, setScriptResults] = useState<SearchResult[]>([])
const scriptDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -95,6 +96,24 @@ export function useGlobalSearch(content: SiteContent) {
.catch(() => {})
}, [])
// Fetch published newsletter issues once on mount
useEffect(() => {
fetch('/api/newsletter/issues')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { issues?: Array<{ id: string; slug: string; title: string; description?: string; sentAt?: string | null }> }) => {
const items: SearchResult[] = (data.issues ?? []).map(issue => ({
id: `newsletter-${issue.id}`,
type: 'newsletter' as const,
title: issue.title,
subtitle: 'Newsletter',
description: issue.description,
href: `/newsletter/${issue.slug}`,
}))
setNewsletterItems(items)
})
.catch(() => {})
}, [])
// Season number → book/study title, so script results can show "Romans" instead of "Season 3"
const seasonTitleMap = useMemo(() => {
const map = new Map<string, string>()
@@ -164,7 +183,7 @@ export function useGlobalSearch(content: SiteContent) {
return results
}, [content, questions])
const allFuseItems = useMemo(() => [...episodeItems, ...nuggetsItems, ...staticItems], [episodeItems, nuggetsItems, staticItems])
const allFuseItems = useMemo(() => [...episodeItems, ...nuggetsItems, ...newsletterItems, ...staticItems], [episodeItems, nuggetsItems, newsletterItems, staticItems])
const fuse = useMemo(() => new Fuse(allFuseItems, {
keys: [