Add Compare with Resend panel to Subscribers; stop resubscribing opted-out contacts; v2.6.1
Publish Container / build (push) Successful in 16m27s

- Subscribers: compare in-app list with Resend audience/segment, with one-click fixes
- syncContactToResend no longer resets unsubscribed on existing contacts

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 08:29:56 -04:00
1 parent e74bd1df5d
commit af967d0049
5 files changed
+273 -6

No files matched your search

+97
View File
@@ -662,6 +662,19 @@ interface Subscriber {
}
interface ResendComparison {
appCount: number
resendCount: number
resendUnsubscribedCount: number
segmentConfigured: boolean
segmentCount: number | null
matched: number
onlyInApp: Array<{ name: string; email: string }>
onlyInResend: Array<{ name: string; email: string }>
unsubscribedInResend: Array<{ name: string; email: string }>
notInSegment: Array<{ name: string; email: string }>
}
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'redirects' | 'podcastFeaturedLinks' | 'finishedBooks' | 'testimonials' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
type AdminView =
@@ -1583,6 +1596,10 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [navSearch, setNavSearch] = useState('')
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
const [resendCmp, setResendCmp] = useState<ResendComparison | null>(null)
const [resendCmpLoading, setResendCmpLoading] = useState(false)
const [resendCmpBusy, setResendCmpBusy] = useState(false)
const [resendCmpMsg, setResendCmpMsg] = useState('')
const [subscriberSearch, setSubscriberSearch] = useState('')
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
// Study comments moderation state
@@ -3495,12 +3512,92 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
s.name.toLowerCase().includes(subscriberSearch.toLowerCase()) ||
s.email.toLowerCase().includes(subscriberSearch.toLowerCase())
)
async function runResendCompare() {
setResendCmpLoading(true); setResendCmpMsg('')
try {
const r = await fetch('/api/admin-subscribers/resend-compare')
const d = await r.json()
if (!r.ok) setResendCmpMsg(d.message || 'Comparison failed.')
else setResendCmp(d as ResendComparison)
} catch { setResendCmpMsg('Network error. Try again.') }
finally { setResendCmpLoading(false) }
}
async function runResendSync(action: 'add-to-resend' | 'add-to-segment' | 'apply-unsubscribes', prompt: string) {
if (!confirm(prompt)) return
setResendCmpBusy(true); setResendCmpMsg('')
try {
const r = await fetch('/api/admin-subscribers/resend-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
})
const d = await r.json()
if (!r.ok) { setResendCmpMsg(d.message || 'Sync failed.'); return }
setResendCmpMsg(`Done: ${d.done} updated${d.failed ? `, ${d.failed} failed` : ''}${d.remaining ? `, ${d.remaining} more — run the action again` : ''}.`)
const subsRes = await fetch('/api/admin-subscribers')
if (subsRes.ok) setSubscribers(((await subsRes.json()) as { subscribers: Subscriber[] }).subscribers ?? [])
await runResendCompare()
} catch { setResendCmpMsg('Network error. Try again.') }
finally { setResendCmpBusy(false) }
}
const renderCmpList = (title: string, note: string, rows: Array<{ name: string; email: string }>, action?: React.ReactNode) => (
<details className="cmp-group" open={rows.length > 0 && rows.length <= 10}>
<summary>
<span className={`cmp-count${rows.length === 0 ? ' is-zero' : ''}`}>{rows.length}</span>
<span className="cmp-title">{title}</span>
</summary>
<p className="cmp-note">{note}</p>
{rows.length > 0 && (
<ul className="cmp-rows">
{rows.slice(0, 200).map(r => <li key={r.email}><span>{r.name || '—'}</span><a href={`mailto:${r.email}`}>{r.email}</a></li>)}
{rows.length > 200 && <li className="cmp-more">…and {rows.length - 200} more</li>}
</ul>
)}
{rows.length > 0 && action}
</details>
)
return (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Subscribers</h2>
<p>{subscribers.length} people have opted in to email updates.</p>
</div>
<div className="cmp-card">
<div className="cmp-head">
<div>
<strong>Compare with Resend</strong>
<p className="cmp-note" style={{ margin: '0.2rem 0 0' }}>Checks that everyone who opted in here is also in your Resend audience, and that nobody who unsubscribed there is still active here.</p>
</div>
<button type="button" className="btn-admin-reset" disabled={resendCmpLoading || resendCmpBusy} onClick={() => void runResendCompare()}>
{resendCmpLoading ? 'Checking Resend…' : resendCmp ? 'Re-check' : 'Run comparison'}
</button>
</div>
{resendCmpMsg && <p className="admin-stats-note" role="status">{resendCmpMsg}</p>}
{resendCmp && (() => {
const c = resendCmp
const inSync = c.onlyInApp.length === 0 && c.unsubscribedInResend.length === 0 && c.notInSegment.length === 0
return (
<>
<div className="cmp-stats">
<div><b>{c.appCount}</b><span>Subscribers here</span></div>
<div><b>{c.resendCount}</b><span>Contacts in Resend{c.resendUnsubscribedCount ? ` (${c.resendUnsubscribedCount} unsubscribed)` : ''}</span></div>
{c.segmentCount !== null && <div><b>{c.segmentCount}</b><span>In your Resend segment</span></div>}
<div><b>{c.matched}</b><span>Fully matched</span></div>
</div>
{inSync && <p className="cmp-ok">✓ Every subscriber here is in Resend and active. {c.onlyInResend.length > 0 ? `Resend also has ${c.onlyInResend.length} other active contact${c.onlyInResend.length === 1 ? '' : 's'} (see below).` : ''}</p>}
{!c.segmentConfigured && <p className="cmp-note">Note: RESEND_SEGMENT_ID isn’t set on the server, so segment membership wasn’t checked and new contacts aren’t added to a segment.</p>}
{renderCmpList('Subscribed here, missing from Resend', 'These people opted in on the site but never made it into Resend, so Resend broadcasts won’t reach them.', c.onlyInApp,
<button type="button" className="btn-admin-apply" disabled={resendCmpBusy} onClick={() => void runResendSync('add-to-resend', `Add ${c.onlyInApp.length} subscriber${c.onlyInApp.length === 1 ? '' : 's'} to Resend? (Up to 100 at a time.)`)}>{resendCmpBusy ? 'Working…' : `Add ${Math.min(c.onlyInApp.length, 100)} to Resend`}</button>)}
{renderCmpList('Unsubscribed in Resend, still active here', 'They opted out through Resend. Marking them unsubscribed here stops the in-app Broadcast Email from sending to them.', c.unsubscribedInResend,
<button type="button" className="btn-admin-apply" disabled={resendCmpBusy} onClick={() => void runResendSync('apply-unsubscribes', `Mark ${c.unsubscribedInResend.length} person${c.unsubscribedInResend.length === 1 ? '' : 's'} as unsubscribed here? They will be removed from the subscriber list and future broadcasts.`)}>{resendCmpBusy ? 'Working…' : 'Mark unsubscribed here'}</button>)}
{c.segmentConfigured && renderCmpList('In Resend, but not in your segment', 'These subscribers exist in Resend but aren’t in the segment your broadcasts use.', c.notInSegment,
<button type="button" className="btn-admin-apply" disabled={resendCmpBusy} onClick={() => void runResendSync('add-to-segment', `Add ${c.notInSegment.length} contact${c.notInSegment.length === 1 ? '' : 's'} to the Resend segment? (Up to 100 at a time.)`)}>{resendCmpBusy ? 'Working…' : `Add ${Math.min(c.notInSegment.length, 100)} to segment`}</button>)}
{renderCmpList('In Resend, not subscribed here', 'Informational only. These are usually study-account signups, or people who opted in before the site tracked it. Nothing is changed for them.', c.onlyInResend)}
</>
)
})()}
</div>
<div className="admin-toolbar" style={{ marginBottom: '1rem', display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="search"