Send app broadcasts as Resend Broadcasts; add selective import from Resend; v2.7.0
Publish Container / build (push) Successful in 15m22s
Publish Container / build (push) Successful in 15m22s
- Broadcast Email creates and sends a Resend Broadcast to the Resend segment (unsubscribe handling, reports, New Episode topic, scheduling, test send) - Subscribers: import chosen Resend-only contacts into the app list Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
af967d0049
commit
66cdb9c4c9
5 files changed
+259
-53
No files matched your search
+108
-20
@@ -658,7 +658,7 @@ interface Subscriber {
|
||||
name: string
|
||||
email: string
|
||||
subscribedAt: string
|
||||
source: 'contact-form' | 'download'
|
||||
source: 'contact-form' | 'download' | 'resend-import'
|
||||
}
|
||||
|
||||
|
||||
@@ -1600,6 +1600,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [resendCmpLoading, setResendCmpLoading] = useState(false)
|
||||
const [resendCmpBusy, setResendCmpBusy] = useState(false)
|
||||
const [resendCmpMsg, setResendCmpMsg] = useState('')
|
||||
const [resendImportSel, setResendImportSel] = useState<Set<string>>(new Set())
|
||||
const [subscriberSearch, setSubscriberSearch] = useState('')
|
||||
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
|
||||
// Study comments moderation state
|
||||
@@ -1655,7 +1656,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [broadcastSubject, setBroadcastSubject] = useState('')
|
||||
const [broadcastBody, setBroadcastBody] = useState('')
|
||||
const [broadcastSending, setBroadcastSending] = useState(false)
|
||||
const [broadcastResult, setBroadcastResult] = useState<{ sent: number; failed: number; total: number } | null>(null)
|
||||
const [broadcastResult, setBroadcastResult] = useState<{ broadcastId: string; scheduledAt: string | null } | null>(null)
|
||||
const [bcPreviewText, setBcPreviewText] = useState('')
|
||||
const [bcTagNewEpisode, setBcTagNewEpisode] = useState(false)
|
||||
const [bcSchedule, setBcSchedule] = useState('')
|
||||
const [bcTestTo, setBcTestTo] = useState('')
|
||||
const [bcTesting, setBcTesting] = useState(false)
|
||||
const [bcAudience, setBcAudience] = useState<{ segmentConfigured: boolean; count: number | null } | null>(null)
|
||||
const [bcAudienceLoading, setBcAudienceLoading] = useState(false)
|
||||
const [broadcastMsg, setBroadcastMsg] = useState('')
|
||||
const [broadcastConfirm, setBroadcastConfirm] = useState(false)
|
||||
const [bcTemplates, setBcTemplates] = useState<Array<{ id: string; name: string; status: string }>>([])
|
||||
@@ -3399,7 +3407,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
<div className="admin-dashboard-card">
|
||||
<div className="admin-dashboard-card-value">{subscribers.length}</div>
|
||||
<div className="admin-dashboard-card-label">Email Subscribers</div>
|
||||
<div className="admin-dashboard-card-sub">{contactSubmissions.length} total contact submissions</div>
|
||||
<div className="admin-dashboard-card-sub">{contactSubmissions.filter(c => c.source !== 'resend-import').length} total contact submissions</div>
|
||||
<button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('subscribers')}>View List →</button>
|
||||
</div>
|
||||
<div className="admin-dashboard-card">
|
||||
@@ -3540,7 +3548,28 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
} catch { setResendCmpMsg('Network error. Try again.') }
|
||||
finally { setResendCmpBusy(false) }
|
||||
}
|
||||
const renderCmpList = (title: string, note: string, rows: Array<{ name: string; email: string }>, action?: React.ReactNode) => (
|
||||
async function importFromResend() {
|
||||
const emails = [...resendImportSel]
|
||||
if (emails.length === 0) return
|
||||
if (!confirm(`Add ${emails.length} contact${emails.length === 1 ? '' : 's'} from Resend to your subscriber list here?\n\nOnly do this for people who agreed to receive your emails.`)) return
|
||||
setResendCmpBusy(true); setResendCmpMsg('')
|
||||
try {
|
||||
const r = await fetch('/api/admin-subscribers/import-from-resend', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ emails }),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setResendCmpMsg(d.message || 'Import failed.'); return }
|
||||
setResendCmpMsg(`Imported ${d.imported}${d.skipped ? `, skipped ${d.skipped}` : ''}.`)
|
||||
setResendImportSel(new Set())
|
||||
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, selectable = false) => (
|
||||
<details className="cmp-group" open={rows.length > 0 && rows.length <= 10}>
|
||||
<summary>
|
||||
<span className={`cmp-count${rows.length === 0 ? ' is-zero' : ''}`}>{rows.length}</span>
|
||||
@@ -3549,7 +3578,17 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
<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>)}
|
||||
{selectable && (
|
||||
<li className="cmp-selectall">
|
||||
<label><input type="checkbox" checked={rows.slice(0, 200).every(r => resendImportSel.has(r.email))} onChange={e => setResendImportSel(e.target.checked ? new Set(rows.slice(0, 200).map(r => r.email)) : new Set())} /> Select all{rows.length > 200 ? ' (first 200)' : ''}</label>
|
||||
</li>
|
||||
)}
|
||||
{rows.slice(0, 200).map(r => (
|
||||
<li key={r.email}>
|
||||
{selectable && <input type="checkbox" checked={resendImportSel.has(r.email)} onChange={() => setResendImportSel(prev => { const n = new Set(prev); if (n.has(r.email)) n.delete(r.email); else n.add(r.email); return n })} aria-label={`Select ${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>
|
||||
)}
|
||||
@@ -3593,7 +3632,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
<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)}
|
||||
{renderCmpList('In Resend, not subscribed here', 'These are usually study-account signups, or people who opted in before the site tracked it. Nothing changes unless you tick people and import them — only import people who agreed to receive your emails.', c.onlyInResend,
|
||||
<button type="button" className="btn-admin-apply" disabled={resendCmpBusy || resendImportSel.size === 0} onClick={() => void importFromResend()}>{resendCmpBusy ? 'Working…' : `Import ${resendImportSel.size} selected`}</button>, true)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
@@ -3628,7 +3668,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
<tr key={`${sub.email}-${i}`}>
|
||||
<td>{sub.name}</td>
|
||||
<td><a href={`mailto:${sub.email}`}>{sub.email}</a></td>
|
||||
<td>{sub.source === 'download' ? 'Download' : 'Contact Form'}</td>
|
||||
<td>{sub.source === 'download' ? 'Download' : sub.source === 'resend-import' ? 'Imported from Resend' : 'Contact Form'}</td>
|
||||
<td>{formatDate(sub.subscribedAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -6126,10 +6166,31 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
|
||||
{/* ── BROADCAST EMAIL ── */}
|
||||
{adminView === 'broadcast' && (() => {
|
||||
const recipientCount = (() => {
|
||||
const seen = new Set<string>()
|
||||
return subscribers.filter(s => s.email && !seen.has(s.email) && !!seen.add(s.email)).length
|
||||
})()
|
||||
const recipientCount = bcAudience?.count ?? null
|
||||
async function loadBcAudience() {
|
||||
setBcAudienceLoading(true)
|
||||
try {
|
||||
const r = await fetch('/api/admin-broadcast/audience')
|
||||
const d = await r.json()
|
||||
setBcAudience(r.ok ? d : { segmentConfigured: false, count: null })
|
||||
if (!r.ok) setBroadcastMsg(d.message || 'Could not read the Resend segment.')
|
||||
} catch { setBcAudience({ segmentConfigured: false, count: null }) }
|
||||
finally { setBcAudienceLoading(false) }
|
||||
}
|
||||
if (bcAudience === null && !bcAudienceLoading) void loadBcAudience()
|
||||
async function sendBcTest() {
|
||||
setBcTesting(true); setBroadcastMsg(''); setBroadcastResult(null)
|
||||
try {
|
||||
const r = await fetch('/api/admin-broadcast/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subject: broadcastSubject, html: broadcastBody, to: bcTestTo }),
|
||||
})
|
||||
const d = await r.json()
|
||||
setBroadcastMsg(r.ok ? `Test sent to ${bcTestTo}.` : d.message || 'Test failed.')
|
||||
} catch { setBroadcastMsg('Network error. Try again.') }
|
||||
finally { setBcTesting(false) }
|
||||
}
|
||||
async function loadBcTemplates() {
|
||||
setBcTplLoading(true); setBcTplNote('')
|
||||
try {
|
||||
@@ -6154,7 +6215,6 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
setBroadcastBody(d.html || '')
|
||||
setBroadcastConfirm(false)
|
||||
const warnings: string[] = []
|
||||
if (d.hasUnsubscribeLink) warnings.push('This template has Resend’s unsubscribe link, which only works in Resend-sent broadcasts — replace it before sending.')
|
||||
if (Array.isArray(d.unresolved) && d.unresolved.length) warnings.push(`Unfilled variables: ${d.unresolved.join(', ')}.`)
|
||||
setBcTplNote(`Loaded “${d.name}”. ${warnings.join(' ')}`.trim())
|
||||
} catch { setBcTplNote('Network error loading template.') }
|
||||
@@ -6168,12 +6228,20 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
const r = await fetch('/api/admin-broadcast', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subject: broadcastSubject, html: broadcastBody }),
|
||||
body: JSON.stringify({
|
||||
subject: broadcastSubject,
|
||||
html: broadcastBody,
|
||||
previewText: bcPreviewText,
|
||||
tagNewEpisode: bcTagNewEpisode,
|
||||
scheduledAt: bcSchedule ? new Date(bcSchedule).toISOString() : undefined,
|
||||
}),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setBroadcastMsg(d.message || 'Send failed.') } else {
|
||||
setBroadcastResult(d); setBroadcastConfirm(false)
|
||||
setBroadcastMsg(`Sent to ${d.sent} subscriber${d.sent !== 1 ? 's' : ''}${d.failed > 0 ? ` (${d.failed} failed)` : ''}.`)
|
||||
setBroadcastMsg(d.scheduledAt
|
||||
? `Scheduled in Resend for ${new Date(d.scheduledAt).toLocaleString()}.`
|
||||
: 'Sent through Resend. Delivery and open stats are in your Resend dashboard under Broadcasts.')
|
||||
}
|
||||
} catch { setBroadcastMsg('Network error. Try again.') }
|
||||
finally { setBroadcastSending(false) }
|
||||
@@ -6182,7 +6250,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
<section className="admin-panel-section">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Broadcast Email</h2>
|
||||
<p>Compose and send a one-off email to all {subscribers.length} subscribers.</p>
|
||||
<p>Compose an email and send it as a Resend Broadcast to your Resend segment{recipientCount !== null ? ` (${recipientCount} active contacts)` : ''}. Unsubscribe links and opt-outs are handled by Resend.</p>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="bc-template">Start from a Resend template</label>
|
||||
@@ -6200,9 +6268,28 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
<input id="bc-subject" type="text" value={broadcastSubject} onChange={e => setBroadcastSubject(e.target.value)} placeholder="This week's episode + new study lesson" />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="bc-body">Body <span style={{ fontWeight: 400, opacity: 0.6, fontSize: '0.8em' }}>(HTML — {'{{name}}'} is replaced with each subscriber’s name)</span></label>
|
||||
<label htmlFor="bc-body">Body <span style={{ fontWeight: 400, opacity: 0.6, fontSize: '0.8em' }}>(HTML — {'{{name}}'} becomes each contact’s first name)</span></label>
|
||||
<textarea id="bc-body" rows={14} value={broadcastBody} onChange={e => setBroadcastBody(e.target.value)} placeholder={'<p>Hi {{name}},</p>\n<p>This week we covered...</p>'} style={{ fontFamily: 'monospace', fontSize: '0.85rem' }} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="bc-preview">Preview text <span style={{ fontWeight: 400, opacity: 0.6, fontSize: '0.8em' }}>(optional — shown after the subject in the inbox)</span></label>
|
||||
<input id="bc-preview" type="text" value={bcPreviewText} onChange={e => setBcPreviewText(e.target.value)} maxLength={200} />
|
||||
</div>
|
||||
<div className="admin-field" style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<input type="checkbox" checked={bcTagNewEpisode} onChange={e => setBcTagNewEpisode(e.target.checked)} />
|
||||
Tag as “New Episode” (only contacts subscribed to that topic; shows up in Newsletter Archive)
|
||||
</label>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
Schedule
|
||||
<input type="datetime-local" value={bcSchedule} onChange={e => setBcSchedule(e.target.value)} />
|
||||
{bcSchedule && <button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => setBcSchedule('')}>Send now instead</button>}
|
||||
</label>
|
||||
</div>
|
||||
<div className="admin-field" style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input type="email" placeholder="Send a test to…" value={bcTestTo} onChange={e => setBcTestTo(e.target.value)} style={{ flex: '1 1 240px', minWidth: 0 }} />
|
||||
<button type="button" className="btn-admin-reset" disabled={bcTesting || !bcTestTo.trim() || !broadcastSubject.trim() || !broadcastBody.trim()} onClick={() => void sendBcTest()}>{bcTesting ? 'Sending test…' : 'Send test'}</button>
|
||||
</div>
|
||||
{broadcastBody.trim() && (
|
||||
<details style={{ marginBottom: '1rem' }}>
|
||||
<summary style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.7 }}>Preview rendered HTML</summary>
|
||||
@@ -6210,14 +6297,15 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
dangerouslySetInnerHTML={{ __html: broadcastBody.replace(/\{\{name\}\}/g, 'Nate (preview)') }} />
|
||||
</details>
|
||||
)}
|
||||
{broadcastMsg && <p className={broadcastResult ? 'admin-save-ok' : 'admin-save-error'} style={{ marginBottom: '0.75rem' }}>{broadcastMsg}</p>}
|
||||
{bcAudience?.segmentConfigured === false && <p className="admin-save-error">RESEND_SEGMENT_ID isn’t set on the server, so there’s no Resend segment to send to.</p>}
|
||||
{broadcastMsg && <p className={broadcastResult || /^Test sent/.test(broadcastMsg) ? 'admin-save-ok' : 'admin-save-error'} style={{ marginBottom: '0.75rem' }}>{broadcastMsg}</p>}
|
||||
{!broadcastConfirm ? (
|
||||
<button type="button" className="btn-admin-apply" disabled={!broadcastSubject.trim() || !broadcastBody.trim()} onClick={() => setBroadcastConfirm(true)}>
|
||||
Send to {recipientCount} subscriber{recipientCount !== 1 ? 's' : ''}…
|
||||
<button type="button" className="btn-admin-apply" disabled={!broadcastSubject.trim() || !broadcastBody.trim() || bcAudience?.segmentConfigured === false} onClick={() => setBroadcastConfirm(true)}>
|
||||
{bcSchedule ? 'Schedule' : 'Send'}{recipientCount !== null ? ` to ${recipientCount} contact${recipientCount !== 1 ? 's' : ''}` : ''}…
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<strong style={{ color: 'var(--brand-gold)' }}>Send "{broadcastSubject}" to {recipientCount} people?</strong>
|
||||
<strong style={{ color: 'var(--brand-gold)' }}>{bcSchedule ? 'Schedule' : 'Send'} "{broadcastSubject}"{recipientCount !== null ? ` to ${recipientCount} people` : ' to your Resend segment'}? This can’t be undone once it starts sending.</strong>
|
||||
<button type="button" className="btn-admin-apply" disabled={broadcastSending} onClick={sendBroadcast}>
|
||||
{broadcastSending ? 'Sending…' : 'Confirm Send'}
|
||||
</button>
|
||||
|
||||
Reference in new issue
Block a user