Send app broadcasts as Resend Broadcasts; add selective import from Resend; v2.7.0
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:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 08:54:23 -04:00
1 parent af967d0049
commit 66cdb9c4c9
5 files changed
+259 -53

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "2.6.1", "version": "2.7.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+143 -30
View File
@@ -407,7 +407,7 @@ export function register(app) {
name: entry.name, name: entry.name,
email: entry.email, email: entry.email,
subscribedAt: entry.submittedAt, subscribedAt: entry.submittedAt,
source: entry.message?.startsWith('Requested') ? 'download' : 'contact-form', source: entry.source === 'resend-import' ? 'resend-import' : entry.message?.startsWith('Requested') ? 'download' : 'contact-form',
})) }))
.sort((a, b) => new Date(b.subscribedAt).getTime() - new Date(a.subscribedAt).getTime()) .sort((a, b) => new Date(b.subscribedAt).getTime() - new Date(a.subscribedAt).getTime())
res.json({ subscribers, total: subscribers.length }) res.json({ subscribers, total: subscribers.length })
@@ -632,8 +632,108 @@ export function register(app) {
} }
}) })
// Import chosen Resend-only contacts into the app's subscriber list (explicit, per-person opt-in by the admin).
app.post('/api/admin-subscribers/import-from-resend', requireAdminAuth, async (req, res) => {
const { emails } = req.body ?? {}
if (!Array.isArray(emails) || emails.length === 0 || emails.length > 200 || !emails.every(e => typeof e === 'string')) {
res.status(400).json({ message: 'Choose between 1 and 200 contacts to import.' }); return
}
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const diff = await buildResendComparison()
const allowed = new Map(diff.onlyInResend.map(c => [c.email, c]))
const now = new Date().toISOString()
const created = []
for (const raw of emails) {
const email = raw.trim().toLowerCase()
const contact = allowed.get(email)
if (!contact) continue // not active in Resend, or already a subscriber here
created.push({
id: randomUUID(),
submittedAt: now,
name: contact.name || email,
email,
message: 'Imported from Resend audience',
messageType: 'general',
subscribe: true,
archived: true,
read: true,
source: 'resend-import',
})
}
if (created.length > 0) {
state.contactSubmissions = [...created, ...state.contactSubmissions]
queueContactSubmissionsWrite()
}
res.json({ ok: true, imported: created.length, skipped: emails.length - created.length })
} catch (err) {
console.error('[subscribers] import from resend failed:', err)
res.status(502).json({ message: 'Import failed. Try again in a moment.' })
}
})
// ── Broadcast Email: sent as a real Resend Broadcast to the Resend segment ──────
const UNSUB_FOOTER = '<p style="margin:32px 0 0;font-size:12px;color:#888;text-align:center;"><a href="{{{RESEND_UNSUBSCRIBE_URL}}}" style="color:#888;">Unsubscribe</a></p>'
// App-side {{name}} becomes Resend's per-contact variable; guarantee an unsubscribe link exists.
function toResendBroadcastHtml(html) {
let out = html.replace(/\{\{\{?\s*(?:first_?name|name)\s*\}?\}\}/gi, '{{{FIRST_NAME|Friend}}}')
if (!/RESEND_UNSUBSCRIBE_URL/i.test(out)) {
out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, `${UNSUB_FOOTER}</body>`) : `${out}\n${UNSUB_FOOTER}`
}
return out
}
app.get('/api/admin-broadcast/audience', requireAdminAuth, async (_req, res) => {
const segmentId = process.env.RESEND_SEGMENT_ID
if (!process.env.RESEND_API_KEY || !segmentId) {
res.json({ segmentConfigured: false, count: null }); return
}
try {
const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
const contacts = await listAllResendContacts(resend, segmentId)
res.json({ segmentConfigured: true, count: contacts.filter(c => !c.unsubscribed).length })
} catch (err) {
console.error('[admin-broadcast] audience count failed:', err)
res.status(502).json({ message: 'Could not read the Resend segment.' })
}
})
app.post('/api/admin-broadcast/test', requireAdminAuth, async (req, res) => {
const { subject, html, to } = req.body ?? {}
if (typeof subject !== 'string' || !subject.trim() || typeof html !== 'string' || !html.trim()) {
res.status(400).json({ message: 'Subject and body are required.' }); return
}
if (typeof to !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to.trim())) {
res.status(400).json({ message: 'Enter a valid test email address.' }); return
}
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const preview = toResendBroadcastHtml(html)
.replace(/\{\{\{\s*FIRST_NAME\s*\|\s*([^}]*?)\s*\}\}\}/gi, (_m, fallback) => escapeHtml(fallback || 'Friend'))
.replace(/\{\{\{\s*RESEND_UNSUBSCRIBE_URL\s*\}\}\}/gi, '#')
const resend = new Resend(process.env.RESEND_API_KEY)
const { error } = await resend.emails.send({
from: getResendFromAddress(),
to: to.trim(),
replyTo: getResendReplyToAddress(),
subject: `[TEST] ${subject.trim()}`,
html: preview,
})
if (error) throw new Error(error.message)
res.json({ ok: true })
} catch (err) {
console.error('[admin-broadcast] test send failed:', err)
res.status(502).json({ message: 'Test email failed to send.' })
}
})
app.post('/api/admin-broadcast', requireAdminAuth, async (req, res) => { app.post('/api/admin-broadcast', requireAdminAuth, async (req, res) => {
const { subject, html } = req.body ?? {} const { subject, html, previewText, tagNewEpisode, scheduledAt } = req.body ?? {}
if (!subject || typeof subject !== 'string' || !subject.trim()) { if (!subject || typeof subject !== 'string' || !subject.trim()) {
res.status(400).json({ message: 'Subject is required.' }); return res.status(400).json({ message: 'Subject is required.' }); return
} }
@@ -643,38 +743,51 @@ export function register(app) {
if (!process.env.RESEND_API_KEY) { if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
} }
const segmentId = process.env.RESEND_SEGMENT_ID
const seen = new Set() if (!segmentId) {
const recipients = state.contactSubmissions res.status(500).json({ message: 'RESEND_SEGMENT_ID is not configured, so there is no segment to send to.' }); return
.filter(e => e.subscribe === true && e.email && !seen.has(e.email) && seen.add(e.email)) }
.map(e => ({ name: e.name ?? '', email: e.email })) let scheduled
if (typeof scheduledAt === 'string' && scheduledAt.trim()) {
if (recipients.length === 0) { const when = Date.parse(scheduledAt)
res.status(400).json({ message: 'No subscribers to send to.' }); return if (!Number.isFinite(when) || when < Date.now() + 60 * 1000) {
res.status(400).json({ message: 'Schedule time must be at least a minute in the future.' }); return
}
scheduled = new Date(when).toISOString()
} }
const resend = new Resend(process.env.RESEND_API_KEY) try {
const from = getResendFromAddress() const resend = new Resend(process.env.RESEND_API_KEY)
const replyTo = getResendReplyToAddress()
const BATCH = 50
let sent = 0
let failed = 0
for (let i = 0; i < recipients.length; i += BATCH) { let topicId
const batch = recipients.slice(i, i + BATCH) if (tagNewEpisode === true) {
const emails = batch.map(r => ({ const { data: topics, error: topicsError } = await resend.topics.list()
from, if (topicsError) throw new Error(topicsError.message)
to: r.email, topicId = (topics?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === 'new episode')?.id
reply_to: replyTo, if (!topicId) {
res.status(400).json({ message: 'No "New Episode" topic exists in Resend.' }); return
}
}
const { data, error } = await resend.broadcasts.create({
name: subject.trim().slice(0, 120),
segmentId,
from: getResendFromAddress(),
replyTo: getResendReplyToAddress(),
subject: subject.trim(), subject: subject.trim(),
html: html.replace(/\{\{\{?\s*(?:first_?name|name)\s*\}?\}\}/gi, escapeHtml(r.name || 'Friend')), html: toResendBroadcastHtml(html),
})) ...(typeof previewText === 'string' && previewText.trim() ? { previewText: previewText.trim().slice(0, 200) } : {}),
try { ...(topicId ? { topicId } : {}),
const { error } = await resend.batch.send(emails) send: true,
if (error) { failed += batch.length } else { sent += batch.length } ...(scheduled ? { scheduledAt: scheduled } : {}),
} catch { failed += batch.length } })
} if (error || !data?.id) throw new Error(error?.message || 'Resend did not return a broadcast id.')
res.json({ ok: true, sent, failed, total: recipients.length }) console.log(`[admin-broadcast] ${scheduled ? 'scheduled' : 'sent'} Resend broadcast ${data.id}: ${subject.trim()}`)
res.json({ ok: true, broadcastId: data.id, scheduledAt: scheduled ?? null })
} catch (err) {
console.error('[admin-broadcast] send failed:', err)
res.status(502).json({ message: `Resend rejected the broadcast: ${err instanceof Error ? err.message : 'unknown error'}` })
}
}) })
} }
+108 -20
View File
@@ -658,7 +658,7 @@ interface Subscriber {
name: string name: string
email: string email: string
subscribedAt: 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 [resendCmpLoading, setResendCmpLoading] = useState(false)
const [resendCmpBusy, setResendCmpBusy] = useState(false) const [resendCmpBusy, setResendCmpBusy] = useState(false)
const [resendCmpMsg, setResendCmpMsg] = useState('') const [resendCmpMsg, setResendCmpMsg] = useState('')
const [resendImportSel, setResendImportSel] = useState<Set<string>>(new Set())
const [subscriberSearch, setSubscriberSearch] = useState('') const [subscriberSearch, setSubscriberSearch] = useState('')
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({}) const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
// Study comments moderation state // Study comments moderation state
@@ -1655,7 +1656,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [broadcastSubject, setBroadcastSubject] = useState('') const [broadcastSubject, setBroadcastSubject] = useState('')
const [broadcastBody, setBroadcastBody] = useState('') const [broadcastBody, setBroadcastBody] = useState('')
const [broadcastSending, setBroadcastSending] = useState(false) 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 [broadcastMsg, setBroadcastMsg] = useState('')
const [broadcastConfirm, setBroadcastConfirm] = useState(false) const [broadcastConfirm, setBroadcastConfirm] = useState(false)
const [bcTemplates, setBcTemplates] = useState<Array<{ id: string; name: string; status: string }>>([]) 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">
<div className="admin-dashboard-card-value">{subscribers.length}</div> <div className="admin-dashboard-card-value">{subscribers.length}</div>
<div className="admin-dashboard-card-label">Email Subscribers</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> <button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('subscribers')}>View List →</button>
</div> </div>
<div className="admin-dashboard-card"> <div className="admin-dashboard-card">
@@ -3540,7 +3548,28 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
} catch { setResendCmpMsg('Network error. Try again.') } } catch { setResendCmpMsg('Network error. Try again.') }
finally { setResendCmpBusy(false) } 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}> <details className="cmp-group" open={rows.length > 0 && rows.length <= 10}>
<summary> <summary>
<span className={`cmp-count${rows.length === 0 ? ' is-zero' : ''}`}>{rows.length}</span> <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> <p className="cmp-note">{note}</p>
{rows.length > 0 && ( {rows.length > 0 && (
<ul className="cmp-rows"> <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>} {rows.length > 200 && <li className="cmp-more">…and {rows.length - 200} more</li>}
</ul> </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>)} <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, {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>)} <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}`}> <tr key={`${sub.email}-${i}`}>
<td>{sub.name}</td> <td>{sub.name}</td>
<td><a href={`mailto:${sub.email}`}>{sub.email}</a></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> <td>{formatDate(sub.subscribedAt)}</td>
</tr> </tr>
))} ))}
@@ -6126,10 +6166,31 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
{/* ── BROADCAST EMAIL ── */} {/* ── BROADCAST EMAIL ── */}
{adminView === 'broadcast' && (() => { {adminView === 'broadcast' && (() => {
const recipientCount = (() => { const recipientCount = bcAudience?.count ?? null
const seen = new Set<string>() async function loadBcAudience() {
return subscribers.filter(s => s.email && !seen.has(s.email) && !!seen.add(s.email)).length 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() { async function loadBcTemplates() {
setBcTplLoading(true); setBcTplNote('') setBcTplLoading(true); setBcTplNote('')
try { try {
@@ -6154,7 +6215,6 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
setBroadcastBody(d.html || '') setBroadcastBody(d.html || '')
setBroadcastConfirm(false) setBroadcastConfirm(false)
const warnings: string[] = [] 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(', ')}.`) if (Array.isArray(d.unresolved) && d.unresolved.length) warnings.push(`Unfilled variables: ${d.unresolved.join(', ')}.`)
setBcTplNote(`Loaded “${d.name}”. ${warnings.join(' ')}`.trim()) setBcTplNote(`Loaded “${d.name}”. ${warnings.join(' ')}`.trim())
} catch { setBcTplNote('Network error loading template.') } } catch { setBcTplNote('Network error loading template.') }
@@ -6168,12 +6228,20 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
const r = await fetch('/api/admin-broadcast', { const r = await fetch('/api/admin-broadcast', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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() const d = await r.json()
if (!r.ok) { setBroadcastMsg(d.message || 'Send failed.') } else { if (!r.ok) { setBroadcastMsg(d.message || 'Send failed.') } else {
setBroadcastResult(d); setBroadcastConfirm(false) 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.') } } catch { setBroadcastMsg('Network error. Try again.') }
finally { setBroadcastSending(false) } finally { setBroadcastSending(false) }
@@ -6182,7 +6250,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<section className="admin-panel-section"> <section className="admin-panel-section">
<div className="admin-panel-head"> <div className="admin-panel-head">
<h2>Broadcast Email</h2> <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>
<div className="admin-field"> <div className="admin-field">
<label htmlFor="bc-template">Start from a Resend template</label> <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" /> <input id="bc-subject" type="text" value={broadcastSubject} onChange={e => setBroadcastSubject(e.target.value)} placeholder="This week's episode + new study lesson" />
</div> </div>
<div className="admin-field"> <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' }} /> <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>
<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() && ( {broadcastBody.trim() && (
<details style={{ marginBottom: '1rem' }}> <details style={{ marginBottom: '1rem' }}>
<summary style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.7 }}>Preview rendered HTML</summary> <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)') }} /> dangerouslySetInnerHTML={{ __html: broadcastBody.replace(/\{\{name\}\}/g, 'Nate (preview)') }} />
</details> </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 ? ( {!broadcastConfirm ? (
<button type="button" className="btn-admin-apply" disabled={!broadcastSubject.trim() || !broadcastBody.trim()} onClick={() => setBroadcastConfirm(true)}> <button type="button" className="btn-admin-apply" disabled={!broadcastSubject.trim() || !broadcastBody.trim() || bcAudience?.segmentConfigured === false} onClick={() => setBroadcastConfirm(true)}>
Send to {recipientCount} subscriber{recipientCount !== 1 ? 's' : ''}… {bcSchedule ? 'Schedule' : 'Send'}{recipientCount !== null ? ` to ${recipientCount} contact${recipientCount !== 1 ? 's' : ''}` : ''}…
</button> </button>
) : ( ) : (
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}> <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}> <button type="button" className="btn-admin-apply" disabled={broadcastSending} onClick={sendBroadcast}>
{broadcastSending ? 'Sending…' : 'Confirm Send'} {broadcastSending ? 'Sending…' : 'Confirm Send'}
</button> </button>
+3
View File
@@ -12989,3 +12989,6 @@
.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 { 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-rows li span { min-width: 140px; opacity: 0.85; }
.cmp-more { opacity: 0.6; } .cmp-more { opacity: 0.6; }
.cmp-rows li input[type="checkbox"] { flex-shrink: 0; margin-top: 0.15rem; }
.cmp-selectall { opacity: 0.85; font-weight: 600; }
.cmp-selectall label { display: inline-flex; gap: 0.4rem; align-items: center; cursor: pointer; }
+4 -2
View File
@@ -10,7 +10,7 @@ export interface InboxSubmission {
subscribe: boolean subscribe: boolean
archived?: boolean archived?: boolean
read?: boolean read?: boolean
source?: 'contact-form' | 'download' | 'inbound-email' source?: 'contact-form' | 'download' | 'inbound-email' | 'resend-import'
inboundTo?: string inboundTo?: string
htmlBody?: string | null htmlBody?: string | null
} }
@@ -77,7 +77,9 @@ function fmt(value: string) {
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) return Number.isNaN(d.getTime()) ? '' : d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
} }
export default function InboxPanel({ submissions, setSubmissions }: InboxPanelProps) { export default function InboxPanel({ submissions: allSubmissions, setSubmissions }: InboxPanelProps) {
// Subscribers imported from Resend are bookkeeping records, not messages.
const submissions = useMemo(() => allSubmissions.filter(s => s.source !== 'resend-import'), [allSubmissions])
const [tab, setTab] = useState<Tab>('inbox') const [tab, setTab] = useState<Tab>('inbox')
const [kind, setKind] = useState<'all' | Kind>('all') const [kind, setKind] = useState<'all' | Kind>('all')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')