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

+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 />} />