diff --git a/package.json b/package.json index 5fe0743..b942119 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.11", + "version": "1.1.12", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index 178ab29..0ebf78e 100644 --- a/server.js +++ b/server.js @@ -23,6 +23,7 @@ import { loadEpisodePlaysFromDisk, loadPodcastChecklistFromDisk, loadAnalyticsEventsFromDisk, + loadEmailSettingsFromDisk, createBackupSnapshot, refreshContentCaches, queueHitStatsWrite, @@ -143,6 +144,7 @@ Promise.all([ loadEpisodePlaysFromDisk(), loadPodcastChecklistFromDisk(), loadAnalyticsEventsFromDisk(), + loadEmailSettingsFromDisk(), refreshContentCaches(), ]) .catch(err => { diff --git a/server/config.js b/server/config.js index 4dae9e5..d2dd1d3 100644 --- a/server/config.js +++ b/server/config.js @@ -58,6 +58,7 @@ export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json') export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json') export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json') export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json') +export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json') export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon export const DIST_DIR = path.join(ROOT_DIR, 'dist') diff --git a/server/data.js b/server/data.js index 3ea39a6..8acd857 100644 --- a/server/data.js +++ b/server/data.js @@ -30,6 +30,7 @@ import { DOWNLOAD_COUNTS_FILE, EPISODE_PLAYS_FILE, ANALYTICS_EVENTS_FILE, + EMAIL_SETTINGS_FILE, EMPTY_HIT_STATS, EMPTY_VISITOR_STATS, DEFAULT_REPLY_TEMPLATES, @@ -385,6 +386,36 @@ export function loadReplyHistoryFromDisk() { }) } +// ── Email settings ──────────────────────────────────────────────────────── + +export function queueEmailSettingsWrite() { + state.emailSettingsWritePromise = state.emailSettingsWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + EMAIL_SETTINGS_FILE, + JSON.stringify({ settings: state.emailSettings, updatedAt: new Date().toISOString() }, null, 2), + 'utf8', + ) + }) + .catch(err => { + console.error('[email-settings] failed to write settings:', err) + }) +} + +export function loadEmailSettingsFromDisk() { + return readFile(EMAIL_SETTINGS_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + if (parsed?.settings && typeof parsed.settings === 'object') { + state.emailSettings = { ...state.emailSettings, ...parsed.settings } + } + }) + .catch(() => { + // Keep defaults from state initializer + }) +} + // ── Podcast checklist ────────────────────────────────────────────────────── export function queuePodcastChecklistWrite() { diff --git a/server/email.js b/server/email.js index dd516db..3e8c9cb 100644 --- a/server/email.js +++ b/server/email.js @@ -350,9 +350,11 @@ export function buildContactAdminNotificationTemplate({ } } -export function buildAdminReplyTemplate({ recipientName, message }) { +export function buildAdminReplyTemplate({ recipientName, message, signature }) { const safeRecipientName = escapeHtml(recipientName || 'friend') const safeMessage = escapeHtml(message).replace(/\n/g, '
') + const sig = typeof signature === 'string' && signature.trim() ? signature.trim() : 'Grace and peace,\nVerse by Verse with Nate' + const safeSig = escapeHtml(sig).replace(/\n/g, '
') return `
@@ -369,7 +371,7 @@ export function buildAdminReplyTemplate({ recipientName, message }) { diff --git a/server/routes/contact.js b/server/routes/contact.js index abc98c2..94de20a 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -17,6 +17,7 @@ import { queueDraftQuestionsWrite, queueReplyTemplatesWrite, queueReplyHistoryWrite, + queueEmailSettingsWrite, normalizeContactEmailStatus, normalizeMessageType, sanitizeReplyTemplates, @@ -398,18 +399,54 @@ export function register(app) { res.json({ submissions: state.contactSubmissions.slice(0, 300) }) }) + app.post('/api/admin-contact-submissions/add', requireAdminAuth, (req, res) => { + const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '' + const email = typeof req.body?.email === 'string' ? req.body.email.trim() : '' + const notes = typeof req.body?.notes === 'string' ? req.body.notes.trim().slice(0, 2000) : '' + + if (!name && !email) { + res.status(400).json({ message: 'Name or email is required.' }); return + } + if (email && !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email)) { + res.status(400).json({ message: 'Invalid email address.' }); return + } + + const submission = { + id: randomUUID(), + submittedAt: new Date().toISOString(), + name: name.slice(0, 200), + email, + message: '', + messageType: 'general', + subscribe: false, + archived: false, + source: 'manual', + notes, + emailStatus: normalizeContactEmailStatus(null, false), + } + + state.contactSubmissions.unshift(submission) + state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS) + queueContactSubmissionsWrite() + res.json({ ok: true, submission }) + }) + app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => { const { id } = req.params if (typeof id !== 'string' || !id.trim()) { res.status(400).json({ message: 'Invalid submission id.' }); return } - const archived = req.body?.archived === true + const patch = {} + if (typeof req.body?.archived === 'boolean') patch.archived = req.body.archived + if (typeof req.body?.name === 'string') patch.name = req.body.name.trim().slice(0, 200) + if (typeof req.body?.notes === 'string') patch.notes = req.body.notes.trim().slice(0, 2000) + let found = false state.contactSubmissions = state.contactSubmissions.map(item => { if (item.id !== id) return item found = true - return { ...item, archived } + return { ...item, ...patch } }) if (!found) { @@ -417,7 +454,7 @@ export function register(app) { } queueContactSubmissionsWrite() - res.json({ ok: true, archived }) + res.json({ ok: true }) }) app.delete('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => { @@ -493,11 +530,12 @@ export function register(app) { } const recipientName = splitName(submission.name).firstName || submission.name || 'friend' - const html = buildAdminReplyTemplate({ recipientName, message }) + const signature = state.emailSettings?.signature ?? 'Grace and peace,\nVerse by Verse with Nate' + const html = buildAdminReplyTemplate({ recipientName, message, signature }) const replyToAddress = getResendReplyToAddress() const defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM const fromAddress = ADMIN_REPLY_FROM_OPTIONS.includes(requestedFrom) ? requestedFrom : defaultFrom - const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\n${replyToAddress}` + const text = `Hi ${recipientName},\n\n${message}\n\n${signature}\n${replyToAddress}` const resend = new Resend(process.env.RESEND_API_KEY) const sendResult = await sendResendEmailWithRetry({ @@ -576,11 +614,12 @@ export function register(app) { } const recipientName = toName ? splitName(toName).firstName || toName : 'friend' - const html = buildAdminReplyTemplate({ recipientName, message }) + const signature = state.emailSettings?.signature ?? 'Grace and peace,\nVerse by Verse with Nate' + const html = buildAdminReplyTemplate({ recipientName, message, signature }) const defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM const fromAddress = ADMIN_REPLY_FROM_OPTIONS.includes(requestedFrom) ? requestedFrom : defaultFrom const replyToAddress = getResendReplyToAddress() - const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate` + const text = `Hi ${recipientName},\n\n${message}\n\n${signature}` const resend = new Resend(process.env.RESEND_API_KEY) await sendResendEmailWithRetry({ @@ -617,6 +656,19 @@ export function register(app) { } }) + app.get('/api/admin-email-settings', requireAdminAuth, (_req, res) => { + res.json(state.emailSettings ?? { signature: 'Grace and peace,\nVerse by Verse with Nate' }) + }) + + app.put('/api/admin-email-settings', requireAdminAuth, (req, res) => { + const signature = typeof req.body?.signature === 'string' + ? req.body.signature.slice(0, 1000) + : (state.emailSettings?.signature ?? 'Grace and peace,\nVerse by Verse with Nate') + state.emailSettings = { ...state.emailSettings, signature } + queueEmailSettingsWrite() + res.json({ ok: true, settings: state.emailSettings }) + }) + app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => { const seen = new Set() const subscribers = state.contactSubmissions diff --git a/server/state.js b/server/state.js index 2c43adf..22ad8d2 100644 --- a/server/state.js +++ b/server/state.js @@ -90,6 +90,9 @@ export const state = { qrScans: [], qrCodesWritePromise: Promise.resolve(), + emailSettings: { signature: 'Grace and peace,\nVerse by Verse with Nate' }, + emailSettingsWritePromise: Promise.resolve(), + lastBackupStatus: { ok: true, at: null, error: null, file: null }, lastCachePurgeStatus: { ok: true, at: null, error: null }, lastDeployHookStatus: { ok: true, at: null, error: null }, diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index f36882d..9f2b6d6 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -688,7 +688,7 @@ type AdminView = | 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'downloads' | 'custom-links' | 'content-blocks' | 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes' - | 'subscribers' | 'contacts' | 'study-users' | 'email-templates' + | 'subscribers' | 'study-users' | 'email-templates' | 'seo' | 'legal' | 'security' | 'brand' | 'global' interface AdminSectionLink { @@ -725,7 +725,6 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi options: [ { value: 'questions', label: 'Questions' }, { value: 'study-comments', label: 'Study Comments' }, - { value: 'contacts', label: 'Contacts' }, { value: 'subscribers', label: 'Subscribers' }, { value: 'study-users', label: 'Study Users' }, ], @@ -1123,7 +1122,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [navSearch, setNavSearch] = useState('') const [subscribers, setSubscribers] = useState([]) const [subscriberSearch, setSubscriberSearch] = useState('') - const [contactSearch, setContactSearch] = useState('') const [downloadStats, setDownloadStats] = useState>({}) // Study comments moderation state interface AdminComment { id: string; studySlug: string; sectionId: string; displayName: string; text: string; createdAt: string; isApproved: boolean; approvedAt: string | null } @@ -1420,12 +1418,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { setStatsStatus('ready') } - async function reloadContactSubmissions() { - const r = await fetch('/api/admin-contact-submissions') - if (!r.ok) throw new Error('Could not refresh contact submissions') - const data = await r.json() as { submissions?: ContactSubmission[] } - setContactSubmissions(Array.isArray(data.submissions) ? data.submissions : []) - } useEffect(() => { setQuestionPage(0) @@ -2209,9 +2201,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { if (view === 'questions' && unansweredCount > 0) { return { count: unansweredCount, neutral: false } } - if (view === 'contacts' && contactSubmissions.length > 0) { - return { count: contactSubmissions.length, neutral: true } - } if (view === 'subscribers' && subscribers.length > 0) { return { count: subscribers.length, neutral: true } } @@ -3050,7 +3039,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { {c.message &&
{c.message.slice(0, 100)}{c.message.length > 100 ? '…' : ''}
} ))} - + View All Contacts → ) } @@ -3107,125 +3096,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { ) })()} - {/* CONTACTS */} - {adminView === 'contacts' && (() => { - const searchTerm = contactSearch.trim().toLowerCase() - const grouped = new Map() - - for (const submission of contactSubmissions) { - const emailKey = submission.email.trim().toLowerCase() - const nameKey = submission.name.trim().toLowerCase() - const key = emailKey || nameKey || submission.id - const entries = grouped.get(key) - if (entries) entries.push(submission) - else grouped.set(key, [submission]) - } - - const rolledUp = Array.from(grouped.values()) - .map(entries => { - const sortedEntries = [...entries].sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime()) - const latest = sortedEntries[0] - return { - ...latest, - archived: sortedEntries.every(entry => entry.archived === true), - subscribe: sortedEntries.some(entry => entry.subscribe), - message: latest.message || sortedEntries.find(entry => entry.message)?.message || '', - submissionCount: sortedEntries.length, - allIds: sortedEntries.map(e => e.id), - } - }) - .filter(contact => { - if (!searchTerm) return true - return contact.name.toLowerCase().includes(searchTerm) - || contact.email.toLowerCase().includes(searchTerm) - || contact.message.toLowerCase().includes(searchTerm) - }) - .sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime()) - - return ( -
-
-

Contacts

-

{rolledUp.length} contacts from {contactSubmissions.length} total submissions — repeat senders are grouped together.

-
-
- setContactSearch(e.target.value)} - style={{ minWidth: '260px', maxWidth: '440px', width: '100%' }} - /> - {rolledUp.length} result{rolledUp.length !== 1 ? 's' : ''} -
- {rolledUp.length === 0 - ?

No contacts{contactSearch ? ' match your search' : ' yet'}.

- : ( -
-

Hi ${safeRecipientName},

${safeMessage}
-

Grace and peace,
Verse by Verse with Nate

+

${safeSig}

- - - - - - - - - - - - - {rolledUp.map(c => ( - - - - - - - - - - ))} - -
NameEmailTypeSubscriberDateMessage
-
{c.name}
- {c.submissionCount > 1 &&
{c.submissionCount} submissions
} -
{c.email} - {c.messageType ?? 'contact'} - {c.source === 'inbound-email' && ( - - {c.inboundTo?.includes('nate@') ? 'nate@' : 'hello@'} - - )} - {c.subscribe ? '✓' : ''}{formatDate(c.submittedAt)}{c.message ?? '—'} - -
-
- ) - } - - ) - })()} - {/* SUBSCRIBERS */} {adminView === 'subscribers' && (() => { const filteredSubs = subscribers.filter(s => diff --git a/src/App.css b/src/App.css index 4902da1..431575d 100644 --- a/src/App.css +++ b/src/App.css @@ -10062,3 +10062,751 @@ .em-footer-hint { display: none; } } + +/* ── Contacts Page (/contacts) ────────────────────────────────────────────── */ + +.ct-app { + display: flex; + flex-direction: column; + height: 100dvh; + background: #0f0f0f; + color: #e8e2d5; + font-family: system-ui, -apple-system, sans-serif; + overflow: hidden; +} + +.ct-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.25rem; + background: #1a1a1a; + border-bottom: 1px solid #2a2a2a; + flex-shrink: 0; +} + +.ct-header-brand { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + font-weight: 600; + color: #e8e2d5; +} + +.ct-header-count { + background: #2e2e2e; + color: #9a9080; + font-size: 0.72rem; + font-weight: 500; + padding: 0.15rem 0.5rem; + border-radius: 999px; + margin-left: 0.25rem; +} + +.ct-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.ct-add-banner { + background: #141414; + border-bottom: 1px solid #2a2a2a; + padding: 1rem 1.25rem; + flex-shrink: 0; +} + +.ct-add-form { max-width: 720px; } + +.ct-add-title { + font-size: 0.85rem; + font-weight: 600; + color: #b0a898; + margin: 0 0 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.ct-add-row { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 0.6rem; +} + +.ct-add-label { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.78rem; + color: #9a9080; + flex: 1; + min-width: 180px; +} + +.ct-add-label--full { width: 100%; flex: none; } + +.ct-input { + background: #1e1e1e; + border: 1px solid #333; + border-radius: 6px; + color: #e8e2d5; + padding: 0.45rem 0.65rem; + font-size: 0.88rem; + width: 100%; + box-sizing: border-box; + font-family: inherit; +} + +.ct-input:focus { + outline: none; + border-color: #c8860a; + box-shadow: 0 0 0 2px rgba(200, 134, 10, 0.18); +} + +.ct-notes-input { resize: vertical; } + +.ct-add-actions { margin-top: 0.6rem; } + +.ct-error { + color: #f87171; + font-size: 0.83rem; + margin: 0.4rem 0 0; +} + +.ct-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.65rem 1.25rem; + background: #161616; + border-bottom: 1px solid #242424; + flex-shrink: 0; + flex-wrap: wrap; +} + +.ct-search { + flex: 1; + min-width: 200px; + max-width: 480px; + background: #1e1e1e; + border: 1px solid #2e2e2e; + border-radius: 6px; + color: #e8e2d5; + padding: 0.42rem 0.75rem; + font-size: 0.87rem; +} + +.ct-search:focus { outline: none; border-color: #c8860a; } + +.ct-archived-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.82rem; + color: #7a7060; + cursor: pointer; +} + +.ct-count-label { + font-size: 0.82rem; + color: #7a7060; + margin-left: auto; +} + +.ct-list { + flex: 1; + overflow-y: auto; + padding: 0.75rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.ct-empty { + color: #6a6050; + font-size: 0.9rem; + text-align: center; + margin-top: 2rem; +} + +.ct-card { + display: flex; + gap: 0.9rem; + align-items: flex-start; + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 10px; + padding: 0.85rem 1rem; + transition: border-color 0.15s; +} + +.ct-card:hover { border-color: #3a3a3a; } +.ct-card--archived { opacity: 0.5; } + +.ct-avatar { + width: 38px; + height: 38px; + border-radius: 50%; + background: linear-gradient(135deg, #c8860a, #9a6408); + color: #fff; + font-size: 1rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + user-select: none; +} + +.ct-card-body { flex: 1; min-width: 0; } + +.ct-card-top { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.4rem; +} + +.ct-card-name { + font-size: 0.92rem; + font-weight: 600; + color: #e8e2d5; +} + +.ct-card-email { + font-size: 0.82rem; + color: #c8860a; + text-decoration: none; +} +.ct-card-email:hover { text-decoration: underline; } + +.ct-badge { + font-size: 0.7rem; + font-weight: 600; + padding: 0.15rem 0.45rem; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.ct-badge--form { background: #1e3a2e; color: #4ade80; } +.ct-badge--email { background: #1e2a4a; color: #60a5fa; } +.ct-badge--download { background: #2a2015; color: #f59e0b; } +.ct-badge--manual { background: #2a1a2e; color: #c084fc; } +.ct-badge--sub { background: #0f2a1e; color: #34d399; } + +.ct-count-badge { + font-size: 0.7rem; + background: #2e2e2e; + color: #9a9080; + padding: 0.12rem 0.45rem; + border-radius: 999px; +} + +.ct-card-notes { + font-size: 0.83rem; + color: #a09080; + margin: 0.25rem 0; + line-height: 1.4; +} +.ct-card-notes--empty { color: #554f45; font-style: italic; } + +.ct-card-bottom { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.3rem; + flex-wrap: wrap; +} + +.ct-card-date { + font-size: 0.78rem; + color: #6a6050; + flex-shrink: 0; +} + +.ct-card-preview { + font-size: 0.8rem; + color: #706050; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.ct-card-actions { + display: flex; + flex-direction: column; + gap: 0.35rem; + flex-shrink: 0; + align-self: center; +} + +.ct-edit-form { display: flex; flex-direction: column; gap: 0.55rem; } +.ct-edit-row { display: flex; gap: 0.6rem; } +.ct-edit-label { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.78rem; + color: #9a9080; + flex: 1; +} +.ct-edit-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.2rem; +} + +/* ── Calendar Page (/calendar) ─────────────────────────────────────────────── */ + +.cal-app { + display: flex; + flex-direction: column; + height: 100dvh; + background: #0f0f0f; + color: #e8e2d5; + font-family: system-ui, -apple-system, sans-serif; + overflow: hidden; +} + +.cal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.25rem; + background: #1a1a1a; + border-bottom: 1px solid #2a2a2a; + flex-shrink: 0; + gap: 0.75rem; + flex-wrap: wrap; +} + +.cal-header-left { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.cal-header-right { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.cal-month-title { + font-size: 1.1rem; + font-weight: 600; + color: #e8e2d5; + margin: 0; + min-width: 180px; + text-align: center; +} + +.cal-nav-btn { + background: #2a2a2a; + border: 1px solid #363636; + color: #c0b8a8; + border-radius: 6px; + width: 32px; + height: 32px; + font-size: 1.1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.12s; +} +.cal-nav-btn:hover { background: #333; } + +.cal-saving { + font-size: 0.8rem; + color: #c8860a; + margin-left: 0.25rem; +} + +.cal-scheduling-hint { + font-size: 0.82rem; + color: #c8860a; +} + +.cal-cancel-link { + background: none; + border: none; + color: #c8860a; + cursor: pointer; + text-decoration: underline; + font-size: inherit; + padding: 0; +} + +.cal-new-ep-banner { + background: #141414; + border-bottom: 1px solid #2a2a2a; + padding: 0.9rem 1.25rem; + flex-shrink: 0; +} + +.cal-new-ep-form { max-width: 900px; } + +.cal-new-ep-title { + font-size: 0.82rem; + font-weight: 600; + color: #8a8070; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 0.6rem; +} + +.cal-new-ep-row { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; + margin-bottom: 0.6rem; +} + +.cal-new-ep-label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.77rem; + color: #8a8070; + flex: 1; + min-width: 140px; +} + +.cal-new-ep-actions { display: flex; gap: 0.5rem; } + +.cal-body { + flex: 1; + display: flex; + overflow: hidden; +} + +.cal-main { + flex: 1; + overflow-y: auto; + padding: 0.75rem; + min-width: 0; +} + +.cal-loading { + color: #6a6050; + text-align: center; + margin-top: 3rem; +} + +.cal-dow-row { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; + margin-bottom: 2px; +} + +.cal-dow { + font-size: 0.72rem; + font-weight: 600; + color: #6a6050; + text-align: center; + padding: 0.35rem 0; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.cal-day { + background: #161616; + border: 1px solid #232323; + border-radius: 6px; + min-height: 90px; + padding: 0.4rem; + cursor: default; + display: flex; + flex-direction: column; + transition: border-color 0.12s; +} + +.cal-day--out { opacity: 0.35; } +.cal-day--today { border-color: #c8860a; } +.cal-day--selectable { cursor: pointer; } +.cal-day--selectable:hover { background: #1c1c12; border-color: #c8860a66; } + +.cal-day-num { + font-size: 0.75rem; + font-weight: 600; + color: #7a7060; + display: block; + margin-bottom: 0.3rem; +} +.cal-day--today .cal-day-num { color: #c8860a; } + +.cal-day-events { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; +} + +.cal-ep-chip { + background: linear-gradient(135deg, #1e2e1a, #172015); + border: 1px solid #2a3a24; + color: #6adb88; + font-size: 0.68rem; + padding: 0.2rem 0.4rem; + border-radius: 4px; + cursor: pointer; + text-align: left; + transition: background 0.12s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} +.cal-ep-chip:hover { background: #2a3e24; } + +.cal-ep-chip-title { color: #4a8a58; } + +.cal-sidebar { + width: 220px; + flex-shrink: 0; + background: #141414; + border-left: 1px solid #222; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.cal-sidebar-title { + font-size: 0.78rem; + font-weight: 600; + color: #8a8070; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.75rem 0.9rem 0.5rem; + border-bottom: 1px solid #232323; + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.cal-sidebar-count { + background: #2e2e2e; + color: #9a9080; + font-size: 0.68rem; + padding: 0.1rem 0.4rem; + border-radius: 999px; +} + +.cal-sidebar-hint { + font-size: 0.78rem; + color: #c8860a; + padding: 0.4rem 0.9rem 0; + margin: 0; +} + +.cal-sidebar-empty { + font-size: 0.82rem; + color: #6a6050; + padding: 0.75rem 0.9rem; + margin: 0; +} + +.cal-sidebar-list { + overflow-y: auto; + flex: 1; + padding: 0.4rem 0.6rem; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.cal-sidebar-ep { + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 7px; + padding: 0.5rem 0.65rem; + cursor: pointer; + transition: border-color 0.12s, background 0.12s; +} +.cal-sidebar-ep:hover { border-color: #3a3a3a; } +.cal-sidebar-ep--selected { border-color: #c8860a; background: #1e1a12; } + +.cal-sidebar-ep-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.2rem; +} + +.cal-sidebar-ep-num { + font-size: 0.72rem; + font-weight: 600; + color: #c8860a; +} + +.cal-sidebar-ep-edit { + background: none; + border: none; + color: #5a5040; + cursor: pointer; + font-size: 0.8rem; + padding: 0; + line-height: 1; +} +.cal-sidebar-ep-edit:hover { color: #9a9080; } + +.cal-sidebar-ep-title { + font-size: 0.8rem; + color: #c8b898; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cal-sidebar-ep-series { + font-size: 0.7rem; + color: #6a6050; + margin-top: 0.1rem; +} + +/* Calendar popover */ +.cal-popover-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.cal-popover { + background: #1e1e1e; + border: 1px solid #333; + border-radius: 12px; + width: 340px; + max-width: 90vw; + box-shadow: 0 16px 40px rgba(0,0,0,0.7); + overflow: hidden; +} + +.cal-popover-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.85rem 1rem; + border-bottom: 1px solid #2a2a2a; +} + +.cal-popover-head h3 { + margin: 0; + font-size: 0.95rem; + color: #e8e2d5; +} + +.cal-popover-close { + background: none; + border: none; + color: #6a6050; + font-size: 1.2rem; + cursor: pointer; + line-height: 1; + padding: 0; +} +.cal-popover-close:hover { color: #c0b8a8; } + +.cal-popover-body { + padding: 0.9rem 1rem; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.cal-popover-label { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.78rem; + color: #8a8070; +} + +.cal-popover-actions { + display: flex; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-top: 1px solid #2a2a2a; +} + +/* Email settings panel additions */ +.em-settings-section { + margin-bottom: 1.25rem; +} + +.em-settings-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + font-weight: 600; + color: #b0a898; + margin-bottom: 0.5rem; + flex-wrap: wrap; +} + +.em-settings-note { + font-size: 0.8rem; + color: #6a6050; + margin: 0.2rem 0 0.5rem; + line-height: 1.4; +} + +.em-settings-sig-textarea { + width: 100%; + box-sizing: border-box; + margin-top: 0.4rem; +} + +.em-settings-status { + font-size: 0.72rem; + font-weight: 600; + padding: 0.15rem 0.45rem; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.em-settings-status--ok { background: #1e3a2e; color: #4ade80; } +.em-settings-status--warn { background: #3a1a1a; color: #f87171; } + +.em-compose-signature-text { + white-space: pre-line; +} + +.em-sig-edit-link { + background: none; + border: none; + color: #c8860a; + cursor: pointer; + font-size: 0.75rem; + padding: 0; + text-decoration: underline; +} +.em-sig-edit-link:hover { color: #e0a020; } + +@media (max-width: 600px) { + .ct-card { flex-direction: column; gap: 0.6rem; } + .ct-card-actions { flex-direction: row; } + .cal-sidebar { display: none; } + .cal-month-title { min-width: 120px; font-size: 0.95rem; } +} diff --git a/src/App.tsx b/src/App.tsx index 2166e82..b98c286 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,8 @@ import type { ReactElement } from 'react' import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom' import AdminPage from './AdminPage' import EmailShell from './EmailPage' +import ContactsShell from './ContactsPage' +import CalendarShell from './CalendarPage' import QASection from './components/QASection' import ContactForm from './components/ContactForm' import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy' @@ -2496,6 +2498,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> +} + +interface PodcastChecklistData { + tasks: PodcastChecklistTask[] + episodes: PodcastChecklistEpisode[] +} + +// ── Auth Shell ─────────────────────────────────────────────────────────────── + +export default function CalendarShell() { + const [authState, setAuthState] = useState<'checking' | 'needs-password' | 'needs-totp' | 'ok'>('checking') + const [password, setPassword] = useState('') + const [totp, setTotp] = useState('') + const [authError, setAuthError] = useState('') + const [authBusy, setAuthBusy] = useState(false) + + useEffect(() => { + fetch('/api/admin-auth/status', { credentials: 'include' }) + .then(r => r.json()) + .then((data: { authenticated?: boolean }) => { + setAuthState(data.authenticated ? 'ok' : 'needs-password') + }) + .catch(() => setAuthState('needs-password')) + }, []) + + async function handleLogin(e: React.FormEvent) { + e.preventDefault() + setAuthBusy(true) + setAuthError('') + try { + const res = await fetch('/api/admin-auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + credentials: 'include', + }) + const data = await res.json() as { ok?: boolean; requiresTOTP?: boolean; message?: string } + if (!res.ok) { setAuthError(data.message ?? 'Invalid password.'); setAuthBusy(false); return } + if (data.requiresTOTP) { setAuthState('needs-totp'); setAuthBusy(false); return } + setAuthState('ok') + } catch { setAuthError('Login failed.') } + setAuthBusy(false) + } + + async function handleTotp(e: React.FormEvent) { + e.preventDefault() + setAuthBusy(true) + setAuthError('') + try { + const res = await fetch('/api/admin-auth/totp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: totp }), + credentials: 'include', + }) + const data = await res.json() as { ok?: boolean; message?: string } + if (!res.ok) { setAuthError(data.message ?? 'Invalid code.'); setAuthBusy(false); return } + setAuthState('ok') + } catch { setAuthError('Verification failed.') } + setAuthBusy(false) + } + + if (authState === 'checking') { + return
Loading…
+ } + + if (authState === 'needs-password') { + return ( +
+
+

Release Calendar

+ + {authError &&

{authError}

} + +
+
+ ) + } + + if (authState === 'needs-totp') { + return ( +
+
+

Two-factor code

+ + {authError &&

{authError}

} + +
+
+ ) + } + + return +} + +// ── Calendar Client ────────────────────────────────────────────────────────── + +function toDateKey(date: Date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} + +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +] + +function CalendarClient() { + const today = new Date() + const [checklist, setChecklist] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [viewYear, setViewYear] = useState(today.getFullYear()) + const [viewMonth, setViewMonth] = useState(today.getMonth()) + const [selectedEpisodeId, setSelectedEpisodeId] = useState(null) + const [editEp, setEditEp] = useState(null) + const [editForm, setEditForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '' }) + const [newEpOpen, setNewEpOpen] = useState(false) + const [newForm, setNewForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '' }) + const [newBusy, setNewBusy] = useState(false) + + const load = useCallback(async () => { + try { + const res = await fetch('/api/admin-podcast-checklist', { credentials: 'include' }) + if (res.ok) { + const data = await res.json() as { checklist: PodcastChecklistData } + setChecklist(data.checklist) + } + } catch { /* silent */ } + setLoading(false) + }, []) + + useEffect(() => { load() }, [load]) + + async function save(episodes: PodcastChecklistEpisode[]) { + if (!checklist) return + setSaving(true) + try { + const updated = { ...checklist, episodes } + const res = await fetch('/api/admin-podcast-checklist', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ checklist: updated }), + }) + if (res.ok) setChecklist(updated) + } catch { /* silent */ } + setSaving(false) + } + + const calDays = useMemo(() => { + const first = new Date(viewYear, viewMonth, 1) + const last = new Date(viewYear, viewMonth + 1, 0) + const days: Array<{ date: Date; inMonth: boolean }> = [] + const startDow = (first.getDay() + 6) % 7 // Mon=0 Sun=6 + for (let i = startDow - 1; i >= 0; i--) { + days.push({ date: new Date(viewYear, viewMonth, -i), inMonth: false }) + } + for (let d = 1; d <= last.getDate(); d++) { + days.push({ date: new Date(viewYear, viewMonth, d), inMonth: true }) + } + const rem = (7 - (days.length % 7)) % 7 + for (let i = 1; i <= rem; i++) { + days.push({ date: new Date(viewYear, viewMonth + 1, i), inMonth: false }) + } + return days + }, [viewYear, viewMonth]) + + const episodesByDate = useMemo(() => { + const map = new Map() + for (const ep of (checklist?.episodes ?? [])) { + if (!ep.datePublished?.trim()) continue + const key = ep.datePublished.trim().slice(0, 10) + const arr = map.get(key) ?? [] + arr.push(ep) + map.set(key, arr) + } + return map + }, [checklist]) + + const unscheduled = useMemo( + () => (checklist?.episodes ?? []).filter(ep => !ep.datePublished?.trim()), + [checklist] + ) + + const todayKey = toDateKey(today) + + function prevMonth() { + if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) } + else setViewMonth(m => m - 1) + } + + function nextMonth() { + if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1) } + else setViewMonth(m => m + 1) + } + + function handleDayClick(date: Date, inMonth: boolean) { + if (!inMonth || !selectedEpisodeId || !checklist) return + const dk = toDateKey(date) + const updated = checklist.episodes.map(ep => + ep.id === selectedEpisodeId ? { ...ep, datePublished: dk } : ep + ) + save(updated) + setSelectedEpisodeId(null) + } + + function openEdit(ep: PodcastChecklistEpisode) { + setEditEp(ep) + setEditForm({ + series: ep.series, + episodeNumber: ep.episodeNumber != null ? String(ep.episodeNumber) : '', + title: ep.title, + datePublished: ep.datePublished?.trim() ?? '', + }) + } + + function saveEdit() { + if (!editEp || !checklist) return + const updated = checklist.episodes.map(ep => + ep.id === editEp.id + ? { + ...ep, + series: editForm.series.trim(), + episodeNumber: editForm.episodeNumber.trim() ? Number(editForm.episodeNumber) : null, + title: editForm.title.trim(), + datePublished: editForm.datePublished.trim(), + } + : ep + ) + save(updated) + setEditEp(null) + } + + function unschedule(ep: PodcastChecklistEpisode) { + if (!checklist) return + const updated = checklist.episodes.map(e => + e.id === ep.id ? { ...e, datePublished: '' } : e + ) + save(updated) + setEditEp(null) + } + + async function handleNewEpisode(e: React.FormEvent) { + e.preventDefault() + if (!checklist) return + setNewBusy(true) + const id = typeof crypto?.randomUUID === 'function' + ? crypto.randomUUID() + : `ep-${Date.now()}-${Math.random().toString(36).slice(2)}` + const ep: PodcastChecklistEpisode = { + id, + series: newForm.series.trim(), + episodeNumber: newForm.episodeNumber.trim() ? Number(newForm.episodeNumber) : null, + title: newForm.title.trim(), + datePublished: newForm.datePublished.trim(), + expanded: false, + tasks: {}, + } + const updated = [...checklist.episodes, ep] + await save(updated) + setNewForm({ series: '', episodeNumber: '', title: '', datePublished: '' }) + setNewEpOpen(false) + setNewBusy(false) + } + + return ( +
+
+
+ +

+ {MONTH_NAMES[viewMonth]} {viewYear} +

+ + {saving && Saving…} +
+
+ {selectedEpisodeId && ( + + Click a date to schedule ·{' '} + + + )} + + ← Admin +
+
+ + {newEpOpen && ( +
+
+

New Episode

+
+ + + + +
+
+ +
+
+
+ )} + +
+
+ {loading ? ( +

Loading calendar…

+ ) : ( + <> +
+ {['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => ( +
{d}
+ ))} +
+
+ {calDays.map(({ date, inMonth }, i) => { + const dk = toDateKey(date) + const eps = episodesByDate.get(dk) ?? [] + const isToday = dk === todayKey + const isSelecting = Boolean(selectedEpisodeId) + return ( +
handleDayClick(date, inMonth)} + > + {date.getDate()} +
+ {eps.map(ep => ( + + ))} +
+
+ ) + })} +
+ + )} +
+ + +
+ + {/* Edit popover */} + {editEp && ( +
setEditEp(null)}> +
e.stopPropagation()}> +
+

Edit Episode

+ +
+
+ + + + +
+
+ + + +
+
+
+ )} +
+ ) +} diff --git a/src/ContactsPage.tsx b/src/ContactsPage.tsx new file mode 100644 index 0000000..08ee17b --- /dev/null +++ b/src/ContactsPage.tsx @@ -0,0 +1,487 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface ContactSubmission { + id: string + submittedAt: string + name: string + email: string + message: string + messageType: string + subscribe: boolean + archived?: boolean + source?: string + inboundTo?: string + notes?: string +} + +interface Contact { + key: string + email: string + name: string + source: string | undefined + inboundTo: string | undefined + subscribe: boolean + archived: boolean + submittedAt: string + message: string + notes: string + submissionCount: number + mainId: string + allIds: string[] +} + +// ── Auth Shell ─────────────────────────────────────────────────────────────── + +export default function ContactsShell() { + const [authState, setAuthState] = useState<'checking' | 'needs-password' | 'needs-totp' | 'ok'>('checking') + const [password, setPassword] = useState('') + const [totp, setTotp] = useState('') + const [authError, setAuthError] = useState('') + const [authBusy, setAuthBusy] = useState(false) + + useEffect(() => { + fetch('/api/admin-auth/status', { credentials: 'include' }) + .then(r => r.json()) + .then((data: { authenticated?: boolean }) => { + setAuthState(data.authenticated ? 'ok' : 'needs-password') + }) + .catch(() => setAuthState('needs-password')) + }, []) + + async function handleLogin(e: React.FormEvent) { + e.preventDefault() + setAuthBusy(true) + setAuthError('') + try { + const res = await fetch('/api/admin-auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + credentials: 'include', + }) + const data = await res.json() as { ok?: boolean; requiresTOTP?: boolean; message?: string } + if (!res.ok) { setAuthError(data.message ?? 'Invalid password.'); setAuthBusy(false); return } + if (data.requiresTOTP) { setAuthState('needs-totp'); setAuthBusy(false); return } + setAuthState('ok') + } catch { setAuthError('Login failed.') } + setAuthBusy(false) + } + + async function handleTotp(e: React.FormEvent) { + e.preventDefault() + setAuthBusy(true) + setAuthError('') + try { + const res = await fetch('/api/admin-auth/totp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: totp }), + credentials: 'include', + }) + const data = await res.json() as { ok?: boolean; message?: string } + if (!res.ok) { setAuthError(data.message ?? 'Invalid code.'); setAuthBusy(false); return } + setAuthState('ok') + } catch { setAuthError('Verification failed.') } + setAuthBusy(false) + } + + if (authState === 'checking') { + return
Loading…
+ } + + if (authState === 'needs-password') { + return ( +
+
+

Contacts

+ + {authError &&

{authError}

} + +
+
+ ) + } + + if (authState === 'needs-totp') { + return ( +
+
+

Two-factor code

+ + {authError &&

{authError}

} + +
+
+ ) + } + + return +} + +// ── Contacts Client ────────────────────────────────────────────────────────── + +function ContactsClient() { + const [submissions, setSubmissions] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [showArchived, setShowArchived] = useState(false) + const [editingId, setEditingId] = useState(null) + const [editName, setEditName] = useState('') + const [editNotes, setEditNotes] = useState('') + const [editSaving, setEditSaving] = useState(false) + const [addOpen, setAddOpen] = useState(false) + const [addName, setAddName] = useState('') + const [addEmail, setAddEmail] = useState('') + const [addNotes, setAddNotes] = useState('') + const [addBusy, setAddBusy] = useState(false) + const [addError, setAddError] = useState('') + + const reload = useCallback(async () => { + try { + const res = await fetch('/api/admin-contact-submissions', { credentials: 'include' }) + if (res.ok) { + const data = await res.json() as { submissions: ContactSubmission[] } + setSubmissions(data.submissions ?? []) + } + } catch { /* silent */ } + setLoading(false) + }, []) + + useEffect(() => { reload() }, [reload]) + + const contacts: Contact[] = useMemo(() => { + const grouped = new Map() + for (const s of submissions) { + const key = s.email?.trim().toLowerCase() || s.id + const arr = grouped.get(key) ?? [] + arr.push(s) + grouped.set(key, arr) + } + return Array.from(grouped.values()) + .map(entries => { + const sorted = [...entries].sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime()) + const latest = sorted[0] + return { + key: latest.email?.trim().toLowerCase() || latest.id, + email: latest.email, + name: latest.name, + source: latest.source, + inboundTo: latest.inboundTo, + subscribe: sorted.some(s => s.subscribe), + archived: sorted.every(s => s.archived === true), + submittedAt: latest.submittedAt, + message: latest.message || sorted.find(s => s.message)?.message || '', + notes: latest.notes ?? '', + submissionCount: sorted.length, + mainId: latest.id, + allIds: sorted.map(s => s.id), + } + }) + .sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime()) + }, [submissions]) + + const filtered = contacts.filter(c => { + if (!showArchived && c.archived) return false + if (!search.trim()) return true + const q = search.toLowerCase() + return ( + c.name.toLowerCase().includes(q) || + c.email.toLowerCase().includes(q) || + c.message.toLowerCase().includes(q) || + c.notes.toLowerCase().includes(q) + ) + }) + + function startEdit(c: Contact) { + setEditingId(c.mainId) + setEditName(c.name) + setEditNotes(c.notes) + } + + async function saveEdit() { + if (!editingId) return + setEditSaving(true) + await fetch(`/api/admin-contact-submissions/${encodeURIComponent(editingId)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ name: editName, notes: editNotes }), + }) + setEditSaving(false) + setEditingId(null) + await reload() + } + + async function deleteContact(c: Contact) { + const label = c.name || c.email || 'this contact' + const plural = c.submissionCount > 1 ? `all ${c.submissionCount} submissions` : 'submission' + if (!confirm(`Delete ${plural} from ${label}?`)) return + await Promise.all( + c.allIds.map(id => + fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, { + method: 'DELETE', + credentials: 'include', + }) + ) + ) + await reload() + } + + async function handleAdd(e: React.FormEvent) { + e.preventDefault() + setAddBusy(true) + setAddError('') + try { + const res = await fetch('/api/admin-contact-submissions/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ name: addName, email: addEmail, notes: addNotes }), + }) + const data = await res.json() as { ok?: boolean; message?: string } + if (!res.ok) { setAddError(data.message ?? 'Failed to add contact.'); setAddBusy(false); return } + setAddOpen(false) + setAddName('') + setAddEmail('') + setAddNotes('') + await reload() + } catch { setAddError('Network error.') } + setAddBusy(false) + } + + function sourceBadge(source: string | undefined) { + if (source === 'manual') return manual + if (source === 'inbound-email') return email + if (source === 'download') return download + return form + } + + function fmtDate(iso: string) { + try { + return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + } catch { return '—' } + } + + return ( +
+
+
+ + Contacts + {contacts.length} +
+
+ + Email + ← Admin +
+
+ + {addOpen && ( +
+
+

Add Contact

+
+ + +
+