Add Compare with Resend panel to Subscribers; stop resubscribing opted-out contacts; v2.6.1
Publish Container / build (push) Successful in 16m27s
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:
1 parent
e74bd1df5d
commit
af967d0049
5 files changed
+273
-6
No files matched your search
@@ -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
@@ -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; }
|
||||
Reference in new issue
Block a user