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"
+20
View File
@@ -12969,3 +12969,23 @@
.ib-layout.has-open { grid-template-columns: minmax(0, 1fr); }
.ib-detail { position: static; max-height: none; }
}
/* ── Subscribers ⇄ Resend comparison ── */
.cmp-card { background: #0f0d09; border: 1px solid rgba(201, 168, 76, 0.25); border-radius: 12px; padding: 1rem; margin-bottom: 1.2rem; }
.cmp-head { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; align-items: flex-start; }
.cmp-head > div { flex: 1 1 320px; min-width: 0; }
.cmp-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.6rem; margin: 0.9rem 0; }
.cmp-stats > div { background: #151209; border: 1px solid rgba(201, 168, 76, 0.15); border-radius: 8px; padding: 0.6rem 0.8rem; display: flex; flex-direction: column; gap: 0.15rem; }
.cmp-stats b { font-size: 1.4rem; color: var(--brand-gold); }
.cmp-stats span { font-size: 0.75rem; opacity: 0.75; }
.cmp-ok { color: #7fd48a; font-size: 0.9rem; }
.cmp-note { font-size: 0.8rem; opacity: 0.7; margin: 0.4rem 0; }
.cmp-group { border-top: 1px solid rgba(201, 168, 76, 0.14); padding: 0.6rem 0; }
.cmp-group summary { cursor: pointer; display: flex; align-items: center; gap: 0.6rem; }
.cmp-count { background: rgba(201, 168, 76, 0.2); color: var(--brand-gold); border-radius: 999px; min-width: 1.8rem; text-align: center; padding: 0.1rem 0.5rem; font-weight: 600; font-size: 0.85rem; }
.cmp-count.is-zero { background: rgba(127, 212, 138, 0.15); color: #7fd48a; }
.cmp-title { font-size: 0.92rem; }
.cmp-rows { list-style: none; margin: 0.4rem 0 0.7rem; padding: 0; max-height: 260px; overflow-y: auto; font-size: 0.82rem; }
.cmp-rows li { display: flex; flex-wrap: wrap; gap: 0.2rem 1rem; padding: 0.25rem 0; border-bottom: 1px solid rgba(255, 255, 255, 0.05); overflow-wrap: anywhere; }
.cmp-rows li span { min-width: 140px; opacity: 0.85; }
.cmp-more { opacity: 0.6; }