diff --git a/package.json b/package.json index fd387df..2b97a02 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.2.4", + "version": "2.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index a282747..071a458 100644 --- a/server.js +++ b/server.js @@ -29,6 +29,7 @@ import { loadWebhooksFromDisk, loadStudySessionsFromDisk, queueStudySessionsWrite, + loadNewsletterIssuesFromDisk, createBackupSnapshot, refreshContentCaches, queueHitStatsWrite, @@ -70,6 +71,7 @@ import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js' import { register as registerFeed } from './server/routes/feed.js' import { register as registerQrCodes } from './server/routes/qr-codes.js' import { register as registerBiblePassage } from './server/routes/bible-passage.js' +import { register as registerNewsletter } from './server/routes/newsletter.js' import { register as registerPublic } from './server/routes/public.js' const app = express() @@ -120,6 +122,7 @@ registerNuggetsHostUpload(app) registerFeed(app) registerQrCodes(app) registerBiblePassage(app) +registerNewsletter(app) // Hit-counting middleware (must come before public routes) app.use((req, res, next) => { @@ -168,6 +171,7 @@ Promise.all([ loadAuditLogFromDisk(), loadWebhooksFromDisk(), loadStudySessionsFromDisk(), + loadNewsletterIssuesFromDisk(), refreshContentCaches(), ]) .catch(err => { diff --git a/server/config.js b/server/config.js index a24c135..6a06c70 100644 --- a/server/config.js +++ b/server/config.js @@ -74,6 +74,7 @@ export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.json') export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json') export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json') export const STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.json') +export const NEWSLETTER_ISSUES_FILE = path.join(DATA_DIR, 'newsletter-issues.json') export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon // Seafile integration for Nuggets episode uploads diff --git a/server/data.js b/server/data.js index 8184460..1781162 100644 --- a/server/data.js +++ b/server/data.js @@ -35,6 +35,7 @@ import { AUDIT_LOG_FILE, WEBHOOKS_FILE, STUDY_SESSIONS_FILE, + NEWSLETTER_ISSUES_FILE, EMPTY_HIT_STATS, EMPTY_VISITOR_STATS, DEFAULT_REPLY_TEMPLATES, @@ -1673,3 +1674,27 @@ export function loadQrCodesFromDisk() { state.qrScans = [] }) } + +// ── Newsletter Issues ──────────────────────────────────────────────────────── + +export function queueNewsletterIssuesWrite() { + state.newsletterIssuesWritePromise = state.newsletterIssuesWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile(NEWSLETTER_ISSUES_FILE, JSON.stringify({ issues: state.newsletterIssues }, null, 2), 'utf8') + }) + .catch(err => { + console.error('[newsletter-issues] failed to write:', err) + }) +} + +export function loadNewsletterIssuesFromDisk() { + return readFile(NEWSLETTER_ISSUES_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + state.newsletterIssues = Array.isArray(parsed?.issues) ? parsed.issues : [] + }) + .catch(() => { + state.newsletterIssues = [] + }) +} diff --git a/server/routes/newsletter.js b/server/routes/newsletter.js new file mode 100644 index 0000000..780b065 --- /dev/null +++ b/server/routes/newsletter.js @@ -0,0 +1,243 @@ +import { randomUUID } from 'node:crypto' +import { Resend } from 'resend' +import { requireAdminAuth } from '../auth.js' +import { state } from '../state.js' +import { queueNewsletterIssuesWrite } from '../data.js' +import { escapeHtml } from '../helpers.js' + +const BASE = 'https://versebyversewithnate.us' +const NEW_EPISODE_TOPIC_NAME = 'new episode' + +function truncate(str, max) { + const clean = String(str || '').trim() + if (clean.length <= max) return clean + return `${clean.slice(0, max - 1).trimEnd()}…` +} + +function stripSubjectPrefix(subject) { + return String(subject || '').replace(/^New Episode:?\s*-?\s*/i, '').trim() +} + +function slugify(str) { + return String(str || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) +} + +function uniqueSlug(base) { + const root = slugify(base) || 'issue' + let candidate = root + let n = 2 + while (state.newsletterIssues.some(i => i.slug === candidate)) { + candidate = `${root}-${n}` + n += 1 + } + return candidate +} + +function extractDescription(text, previewText) { + if (previewText && previewText.trim()) return truncate(previewText, 200) + const paragraphs = String(text || '') + .split(/\n{2,}/) + .map(p => p.replace(/\s+/g, ' ').trim()) + .filter(Boolean) + const candidate = paragraphs.find(p => p.length > 60 && !/^[◆✦✍✎▶]/.test(p) && p !== p.toUpperCase()) + return truncate(candidate || paragraphs[paragraphs.length - 1] || '', 200) +} + +// Strip the raw {{{RESEND_UNSUBSCRIBE_URL}}} placeholder link — it only resolves inside an actual sent email. +function stripUnsubscribeLink(html) { + return String(html || '').replace( + /]*>\s*]*href="\{\{\{RESEND_UNSUBSCRIBE_URL\}\}\}"[\s\S]*?<\/a>\s*<\/p>/i, + '', + ) +} + +function buildIssuePageHtml(issue) { + const canonical = `${BASE}/newsletter/${encodeURIComponent(issue.slug)}` + const ogImage = `${BASE}/images/banner.png` + const title = escapeHtml(issue.title) + const description = escapeHtml(issue.description) + + const metaBlock = ` +${title} — Verse by Verse with Nate + + + + + + + + + + + + + +` + + const ctaBlock = ` + +
+ +
+

+← Newsletter Archive +

+Subscribe by Email +
+
+` + + let html = stripUnsubscribeLink(issue.html) + html = html.replace(/[\s\S]*?<\/title>/i, '') + html = /<\/head>/i.test(html) ? html.replace(/<\/head>/i, metaBlock) : html.replace(/<head[^>]*>/i, m => `${m}${metaBlock.replace('</head>', '')}`) + html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, ctaBlock) : `${html}${ctaBlock}` + return html +} + +export function register(app) { + // ── Admin: browse Resend broadcasts, flagged by New Episode topic ──────── + + app.get('/api/admin/newsletter/resend-broadcasts', requireAdminAuth, async (_req, res) => { + if (!process.env.RESEND_API_KEY) { + res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return + } + try { + const resend = new Resend(process.env.RESEND_API_KEY) + const [{ data: topicsData, error: topicsError }, { data: listData, error: listError }] = await Promise.all([ + resend.topics.list(), + resend.broadcasts.list(), + ]) + if (topicsError) throw new Error(topicsError.message) + if (listError) throw new Error(listError.message) + + const newEpisodeTopic = (topicsData?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === NEW_EPISODE_TOPIC_NAME) + const sentBroadcasts = (listData?.data ?? []).filter(b => b.status === 'sent') + + const details = await Promise.all(sentBroadcasts.map(b => resend.broadcasts.get(b.id))) + + const broadcasts = details.map(({ data }, i) => { + const summary = sentBroadcasts[i] + if (!data) return null + return { + id: data.id, + name: data.name, + subject: data.subject, + sentAt: data.sent_at ?? summary.sent_at, + topicId: data.topic_id ?? null, + matchesNewEpisodeTopic: !!newEpisodeTopic && data.topic_id === newEpisodeTopic.id, + alreadyPublished: state.newsletterIssues.some(issue => issue.resendBroadcastId === data.id), + } + }).filter(Boolean).sort((a, b) => new Date(b.sentAt ?? 0).getTime() - new Date(a.sentAt ?? 0).getTime()) + + res.json({ broadcasts, newEpisodeTopicFound: !!newEpisodeTopic }) + } catch (err) { + console.error('[newsletter] failed to list Resend broadcasts:', err) + res.status(502).json({ message: 'Could not reach Resend.' }) + } + }) + + // ── Admin: manage published issues ──────────────────────────────────────── + + app.get('/api/admin/newsletter/issues', requireAdminAuth, (_req, res) => { + const issues = [...state.newsletterIssues] + .sort((a, b) => new Date(b.sentAt ?? b.publishedAt).getTime() - new Date(a.sentAt ?? a.publishedAt).getTime()) + .map(({ html, ...rest }) => rest) + res.json({ issues }) + }) + + app.post('/api/admin/newsletter/issues', requireAdminAuth, async (req, res) => { + const { broadcastId, title: titleOverride, slug: slugOverride, public: isPublic } = req.body ?? {} + if (!broadcastId || typeof broadcastId !== 'string') { + res.status(400).json({ message: 'broadcastId is required.' }); return + } + if (state.newsletterIssues.some(i => i.resendBroadcastId === broadcastId)) { + res.status(409).json({ message: 'This broadcast has already been published.' }); return + } + if (!process.env.RESEND_API_KEY) { + res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return + } + + try { + const resend = new Resend(process.env.RESEND_API_KEY) + const { data, error } = await resend.broadcasts.get(broadcastId) + if (error || !data) throw new Error(error?.message ?? 'Broadcast not found.') + if (!data.html) { res.status(400).json({ message: 'This broadcast has no HTML content.' }); return } + + const title = typeof titleOverride === 'string' && titleOverride.trim() + ? titleOverride.trim().slice(0, 200) + : stripSubjectPrefix(data.subject) || data.name || 'Newsletter Issue' + const slug = typeof slugOverride === 'string' && slugOverride.trim() + ? uniqueSlug(slugOverride) + : uniqueSlug(title) + + const issue = { + id: randomUUID(), + resendBroadcastId: data.id, + slug, + title, + subject: data.subject ?? '', + description: extractDescription(data.text, data.preview_text), + html: data.html, + sentAt: data.sent_at ?? null, + publishedAt: new Date().toISOString(), + public: isPublic !== false, + } + + state.newsletterIssues.push(issue) + queueNewsletterIssuesWrite() + const { html, ...rest } = issue + res.json({ ok: true, issue: rest }) + } catch (err) { + console.error('[newsletter] failed to publish issue:', err) + res.status(502).json({ message: 'Could not fetch broadcast content from Resend.' }) + } + }) + + app.patch('/api/admin/newsletter/issues/:id', requireAdminAuth, (req, res) => { + const issue = state.newsletterIssues.find(i => i.id === req.params.id) + if (!issue) { res.status(404).json({ message: 'Issue not found.' }); return } + + if (typeof req.body?.title === 'string' && req.body.title.trim()) issue.title = req.body.title.trim().slice(0, 200) + if (typeof req.body?.slug === 'string' && req.body.slug.trim() && req.body.slug.trim() !== issue.slug) { + const candidate = slugify(req.body.slug) + if (!candidate) { res.status(400).json({ message: 'Invalid slug.' }); return } + if (state.newsletterIssues.some(i => i.id !== issue.id && i.slug === candidate)) { + res.status(409).json({ message: 'That slug is already in use.' }); return + } + issue.slug = candidate + } + if (typeof req.body?.public === 'boolean') issue.public = req.body.public + + queueNewsletterIssuesWrite() + const { html, ...rest } = issue + res.json({ ok: true, issue: rest }) + }) + + app.delete('/api/admin/newsletter/issues/:id', requireAdminAuth, (req, res) => { + const idx = state.newsletterIssues.findIndex(i => i.id === req.params.id) + if (idx === -1) { res.status(404).json({ message: 'Issue not found.' }); return } + state.newsletterIssues.splice(idx, 1) + queueNewsletterIssuesWrite() + res.json({ ok: true }) + }) + + // ── Public ───────────────────────────────────────────────────────────────── + + app.get('/api/newsletter/issues', (_req, res) => { + const issues = state.newsletterIssues + .filter(i => i.public !== false) + .sort((a, b) => new Date(b.sentAt ?? b.publishedAt).getTime() - new Date(a.sentAt ?? a.publishedAt).getTime()) + .map(({ html, ...rest }) => rest) + res.json({ issues }) + }) + + app.get('/newsletter/:slug', (req, res, next) => { + const issue = state.newsletterIssues.find(i => i.slug === req.params.slug && i.public !== false) + if (!issue) { next(); return } + res.type('html').send(buildIssuePageHtml(issue)) + }) +} diff --git a/server/routes/public.js b/server/routes/public.js index a25449f..671e696 100644 --- a/server/routes/public.js +++ b/server/routes/public.js @@ -106,6 +106,11 @@ export function register(app) { for (const ep of highlights) { if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`) } + // Newsletter archive + published issues + urls.push(`${root}/newsletter`) + for (const issue of state.newsletterIssues) { + if (issue.slug && issue.public !== false) urls.push(`${root}/newsletter/${encodeURIComponent(issue.slug)}`) + } const xml = [ '<?xml version="1.0" encoding="UTF-8"?>', diff --git a/server/state.js b/server/state.js index dbd88ec..05b95e8 100644 --- a/server/state.js +++ b/server/state.js @@ -96,6 +96,10 @@ export const state = { emailSettings: { signature: 'Grace and peace,\nVerse by Verse with Nate' }, emailSettingsWritePromise: Promise.resolve(), + // newsletterIssues: Array<{ id, resendBroadcastId, slug, title, subject, description, html, sentAt, publishedAt, public }> + newsletterIssues: [], + newsletterIssuesWritePromise: Promise.resolve(), + calendarEvents: [], calendarEventsWritePromise: Promise.resolve(), diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 2e456c2..6fba627 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -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() diff --git a/src/App.tsx b/src/App.tsx index b0dd49d..2765f27 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 />} /> diff --git a/src/components/GlobalSearch.tsx b/src/components/GlobalSearch.tsx index c800eae..6d81af5 100644 --- a/src/components/GlobalSearch.tsx +++ b/src/components/GlobalSearch.tsx @@ -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) } diff --git a/src/hooks/useGlobalSearch.ts b/src/hooks/useGlobalSearch.ts index a9dc0b7..4942b18 100644 --- a/src/hooks/useGlobalSearch.ts +++ b/src/hooks/useGlobalSearch.ts @@ -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: [