From e2c560a1ab54007ef9665a44044069bf39857cfb Mon Sep 17 00:00:00 2001 From: nmemmert Date: Tue, 28 Jul 2026 17:00:21 -0400 Subject: [PATCH] Add contacts features: tags, history, CSV import/export, merge, last-contacted; fix CalendarPage unused var; v1.1.23 Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- server/data.js | 6 +- server/routes/contact.js | 62 +++ src/App.css | 254 ++++++++++++ src/CalendarPage.tsx | 3 +- src/ContactsPage.tsx | 857 ++++++++++++++++++++++++++------------- 6 files changed, 902 insertions(+), 282 deletions(-) diff --git a/package.json b/package.json index 6767503..ecfd395 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.22", + "version": "1.1.23", "type": "module", "scripts": { "dev": "vite", diff --git a/server/data.js b/server/data.js index 810e847..9a4b545 100644 --- a/server/data.js +++ b/server/data.js @@ -1113,7 +1113,11 @@ function sanitizeLoadedContactSubmissions(value) { snoozedUntil: typeof entry.snoozedUntil === 'string' && !isNaN(Date.parse(entry.snoozedUntil)) ? entry.snoozedUntil : null, threadId: typeof entry.threadId === 'string' && entry.threadId.trim() ? entry.threadId.trim() : randomUUID(), emailStatus: normalizeContactEmailStatus(entry.emailStatus, entry.subscribe === true), - source: entry.source === 'inbound-email' || entry.source === 'download' ? entry.source : 'contact-form', + source: ['inbound-email', 'download', 'manual'].includes(entry.source) ? entry.source : 'contact-form', + notes: typeof entry.notes === 'string' ? entry.notes.trim().slice(0, 2000) : '', + tags: Array.isArray(entry.tags) + ? [...new Set(entry.tags.filter(t => typeof t === 'string' && t.trim()).map(t => t.trim().slice(0, 50)))].slice(0, 20) + : [], htmlBody: typeof entry.htmlBody === 'string' && entry.htmlBody.trim() ? entry.htmlBody : null, inboundTo: typeof entry.inboundTo === 'string' ? entry.inboundTo : '', messageId: typeof entry.messageId === 'string' ? entry.messageId : '', diff --git a/server/routes/contact.js b/server/routes/contact.js index e7ddb81..11a76b0 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -442,6 +442,9 @@ export function register(app) { if (typeof req.body?.starred === 'boolean') patch.starred = req.body.starred 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) + if (Array.isArray(req.body?.tags)) { + patch.tags = [...new Set(req.body.tags.filter(t => typeof t === 'string' && t.trim()).map(t => t.trim().slice(0, 50)))].slice(0, 20) + } if ('snoozedUntil' in (req.body ?? {})) { const v = req.body.snoozedUntil patch.snoozedUntil = v === null ? null : (typeof v === 'string' && !isNaN(Date.parse(v)) ? v : undefined) @@ -507,6 +510,65 @@ export function register(app) { res.json({ ok: true, affected }) }) + app.post('/api/admin-contacts/merge', requireAdminAuth, (req, res) => { + const keepEmail = typeof req.body?.keepEmail === 'string' ? req.body.keepEmail.trim().toLowerCase() : '' + const mergeEmail = typeof req.body?.mergeEmail === 'string' ? req.body.mergeEmail.trim().toLowerCase() : '' + if (!keepEmail || !mergeEmail || keepEmail === mergeEmail) { + res.status(400).json({ message: 'keepEmail and mergeEmail must be different non-empty addresses.' }); return + } + let affected = 0 + state.contactSubmissions = state.contactSubmissions.map(s => { + if ((s.email ?? '').trim().toLowerCase() !== mergeEmail) return s + affected++ + return { ...s, email: keepEmail } + }) + queueContactSubmissionsWrite() + res.json({ ok: true, affected }) + }) + + app.post('/api/admin-contacts/import', requireAdminAuth, (req, res) => { + const rows = req.body?.rows + if (!Array.isArray(rows) || rows.length === 0) { + res.status(400).json({ message: 'rows must be a non-empty array.' }); return + } + const EMAIL_RE = /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/ + let created = 0 + let skipped = 0 + const toAdd = [] + for (const row of rows.slice(0, 1000)) { + const name = typeof row.name === 'string' ? row.name.trim().slice(0, 200) : '' + const email = typeof row.email === 'string' ? row.email.trim().toLowerCase().slice(0, 320) : '' + const notes = typeof row.notes === 'string' ? row.notes.trim().slice(0, 2000) : '' + const tags = Array.isArray(row.tags) + ? row.tags.filter(t => typeof t === 'string' && t.trim()).map(t => t.trim().slice(0, 50)).slice(0, 20) + : (typeof row.tags === 'string' ? row.tags.split(';').map(t => t.trim()).filter(Boolean).slice(0, 20) : []) + if (!name && !email) { skipped++; continue } + if (email && !EMAIL_RE.test(email)) { skipped++; continue } + toAdd.push({ name, email, notes, tags }) + } + for (const row of toAdd) { + const submission = { + id: randomUUID(), + submittedAt: new Date().toISOString(), + name: row.name, + email: row.email, + message: '', + messageType: 'general', + subscribe: false, + archived: false, + source: 'manual', + notes: row.notes, + tags: row.tags, + emailStatus: normalizeContactEmailStatus(null, false), + } + state.contactSubmissions.unshift(submission) + created++ + } + state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS) + if (created > 0) queueContactSubmissionsWrite() + res.json({ ok: true, created, skipped }) + }) + app.get('/api/admin-contact-submissions/:id/attachments/:attachmentId', requireAdminAuth, (req, res) => { const { id, attachmentId } = req.params const submission = state.contactSubmissions.find(s => s.id === id) diff --git a/src/App.css b/src/App.css index 7ce4f34..1040149 100644 --- a/src/App.css +++ b/src/App.css @@ -10461,6 +10461,260 @@ margin-top: 0.2rem; } +.ct-card-meta-row { + margin-top: 0.2rem; +} + +.ct-card-date { + color: #6b6560; + font-size: 0.78rem; +} + +.ct-last-contacted { + color: #8a8070; +} + +.ct-card-preview { + color: #9a9088; + font-size: 0.82rem; + margin-top: 0.25rem; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} + +/* Tags */ +.ct-tags-row { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.3rem; +} + +.ct-tag { + align-items: center; + border-radius: 12px; + color: rgba(255,255,255,0.85); + display: inline-flex; + font-size: 0.72rem; + font-weight: 500; + gap: 0.25rem; + padding: 0.18rem 0.55rem; +} + +.ct-tag-remove { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1rem; + line-height: 1; + opacity: 0.7; + padding: 0; + margin-left: 0.1rem; +} + +.ct-tag-remove:hover { opacity: 1; } + +.ct-tag-editor { + align-items: center; + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 6px; + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + min-height: 34px; + padding: 0.3rem 0.5rem; +} + +.ct-tag-editor:focus-within { border-color: #c8860a; } + +.ct-tag-input { + background: none; + border: none; + color: #e8e2d5; + flex: 1; + font-size: 0.82rem; + min-width: 80px; + outline: none; + padding: 0; +} + +.ct-tag-input::placeholder { color: #554f45; } + +.ct-tag-filter { + appearance: none; + background: #1a1a1a url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E") no-repeat right 0.6rem center; + border: 1px solid #2a2a2a; + border-radius: 6px; + color: #c0b8a8; + font-size: 0.82rem; + padding: 0.42rem 2rem 0.42rem 0.75rem; + cursor: pointer; +} + +.ct-tag-filter:focus { outline: none; border-color: #c8860a; } + +/* Merge picker */ +.ct-merge-picker { + background: #141414; + border: 1px solid #2a2a2a; + border-radius: 8px; + margin-top: 0.5rem; + padding: 0.75rem; +} + +.ct-merge-label { + color: #a09880; + font-size: 0.82rem; + margin: 0 0 0.5rem; +} + +.ct-merge-search { + margin-bottom: 0.5rem; + width: 100%; +} + +.ct-merge-list { + display: flex; + flex-direction: column; + gap: 0.3rem; + max-height: 240px; + overflow-y: auto; +} + +.ct-merge-option { + align-items: center; + background: #1a1a1a; + border: 1px solid #252525; + border-radius: 6px; + color: #d4c8a8; + cursor: pointer; + display: flex; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + text-align: left; + transition: background 0.12s; +} + +.ct-merge-option:hover:not(:disabled) { background: #222; border-color: #363636; } +.ct-merge-option:disabled { opacity: 0.5; cursor: not-allowed; } + +.ct-merge-name { font-weight: 500; font-size: 0.86rem; } +.ct-merge-email { color: #8a8070; font-size: 0.78rem; flex: 1; } +.ct-merge-count { color: #6b6560; font-size: 0.75rem; } + +/* Conversation history */ +.ct-history-panel { + background: #111; + border-top: 1px solid #1e1e1e; + display: flex; + flex-direction: column; + gap: 0.5rem; + grid-column: 1 / -1; + padding: 0.75rem 1rem; + width: 100%; +} + +.ct-history-empty { + color: #554f45; + font-size: 0.82rem; + font-style: italic; +} + +.ct-history-item { + border-radius: 8px; + padding: 0.5rem 0.75rem; +} + +.ct-history-item--in { + background: #1a1a1a; + border-left: 3px solid #3a5a4a; +} + +.ct-history-item--out { + background: #191419; + border-left: 3px solid #5a3a5a; +} + +.ct-history-meta { + align-items: baseline; + display: flex; + gap: 0.5rem; + margin-bottom: 0.2rem; +} + +.ct-history-who { + color: #c0b8a8; + font-size: 0.78rem; + font-weight: 500; +} + +.ct-history-date { + color: #6b6560; + font-size: 0.72rem; + margin-left: auto; +} + +.ct-history-subject { + color: #a09880; + font-size: 0.78rem; + font-style: italic; + margin-bottom: 0.15rem; +} + +.ct-history-body { + color: #9a9088; + font-size: 0.8rem; + line-height: 1.45; + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} + +/* Import banner */ +.ct-import-banner { + align-items: flex-start; + background: #13181a; + border-bottom: 1px solid #2a3a2a; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + padding: 0.75rem 1.25rem; +} + +.ct-import-info { + color: #d4c8a8; + flex: 1; + font-size: 0.84rem; +} + +.ct-import-cols { + color: #8a8070; +} + +.ct-import-actions { + display: flex; + gap: 0.5rem; +} + +.ct-import-msg { + color: #4ade80; + font-size: 0.82rem; + margin: 0; + width: 100%; +} + +/* Flash message */ +.ct-flash { + background: #1a2a1a; + border-bottom: 1px solid #2a3a2a; + color: #4ade80; + font-size: 0.84rem; + padding: 0.5rem 1.25rem; +} + /* ── Calendar Page (/calendar) ─────────────────────────────────────────────── */ .cal-app { diff --git a/src/CalendarPage.tsx b/src/CalendarPage.tsx index acd6d99..efde7b5 100644 --- a/src/CalendarPage.tsx +++ b/src/CalendarPage.tsx @@ -657,8 +657,7 @@ function CalendarClient() { // ── Render chip ── - function renderEpChip(ep: PodcastChecklistEpisode, dk: string) { - const isScheduled = ep.datePublished === dk + function renderEpChip(ep: PodcastChecklistEpisode, _dk: string) { return ( - - - ) - } + if (authState === 'needs-password') return ( +
+
+

Contacts

+ + {authError &&

{authError}

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

Two-factor code

- - {authError &&

{authError}

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

Two-factor code

+ + {authError &&

{authError}

} + +
+
+ ) return } -// ── Contacts Client ────────────────────────────────────────────────────────── +// ── Contacts Client ─────────────────────────────────────────────────────────── function ContactsClient() { const [submissions, setSubmissions] = useState([]) + const [replyHistory, setReplyHistory] = useState([]) const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') + const [tagFilter, setTagFilter] = useState('') const [showArchived, setShowArchived] = useState(false) - const [editingId, setEditingId] = useState(null) + + // Edit state + const [editingKey, setEditingKey] = useState(null) const [editName, setEditName] = useState('') const [editNotes, setEditNotes] = useState('') + const [editTags, setEditTags] = useState([]) + const [editTagInput, setEditTagInput] = useState('') const [editSaving, setEditSaving] = useState(false) + + // Merge state + const [mergePickerKey, setMergePickerKey] = useState(null) + const [mergeSearch, setMergeSearch] = useState('') + const [mergeBusy, setMergeBusy] = useState(false) + const [mergeMsg, setMergeMsg] = useState('') + + // History state + const [expandedHistoryKey, setExpandedHistoryKey] = useState(null) + + // Add contact const [addOpen, setAddOpen] = useState(false) const [addName, setAddName] = useState('') const [addEmail, setAddEmail] = useState('') const [addNotes, setAddNotes] = useState('') + const [addTags, setAddTags] = useState('') const [addBusy, setAddBusy] = useState(false) const [addError, setAddError] = useState('') + // CSV import + const [importBusy, setImportBusy] = useState(false) + const [importMsg, setImportMsg] = useState('') + const [importPreview, setImportPreview] = useState<{ rows: Record[]; filename: string } | null>(null) + const importFileRef = useRef(null) + + // Flash + const [flashMsg, setFlashMsg] = 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 ?? []) + const [subRes, histRes] = await Promise.all([ + fetch('/api/admin-contact-submissions', { credentials: 'include' }), + fetch('/api/admin-contact-reply-history', { credentials: 'include' }), + ]) + if (subRes.ok) { + const d = await subRes.json() as { submissions: ContactSubmission[] } + setSubmissions(d.submissions ?? []) + } + if (histRes.ok) { + const d = await histRes.json() as { items: ReplyHistoryItem[] } + setReplyHistory(d.items ?? []) } } catch { /* silent */ } setLoading(false) @@ -169,7 +269,10 @@ function ContactsClient() { useEffect(() => { reload() }, [reload]) + // ── Derived contacts ── + const contacts: Contact[] = useMemo(() => { + // Group by email (lowercased), falling back to id const grouped = new Map() for (const s of submissions) { const key = s.email?.trim().toLowerCase() || s.id @@ -177,98 +280,248 @@ function ContactsClient() { arr.push(s) grouped.set(key, arr) } + + // Build last-contacted index from reply history + const lastContactedMap = new Map() + for (const item of replyHistory) { + const email = item.toEmail?.trim().toLowerCase() + if (!email) continue + const existing = lastContactedMap.get(email) + if (!existing || item.sentAt > existing) lastContactedMap.set(email, item.sentAt) + } + 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] + const oldest = sorted[sorted.length - 1] + const emailKey = latest.email?.trim().toLowerCase() || latest.id + // Tags: prefer entry that has tags, falling back to mainId submission + const withTags = sorted.find(s => s.tags && s.tags.length > 0) return { - key: latest.email?.trim().toLowerCase() || latest.id, - email: latest.email, - name: latest.name, + key: emailKey, + 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, + firstContactAt: oldest.submittedAt, + latestAt: latest.submittedAt, message: latest.message || sorted.find(s => s.message)?.message || '', - notes: latest.notes ?? '', + notes: sorted.find(s => s.notes)?.notes ?? '', + tags: withTags?.tags ?? latest.tags ?? [], + lastContactedAt: lastContactedMap.get(emailKey) ?? null, submissionCount: sorted.length, mainId: latest.id, allIds: sorted.map(s => s.id), + allSubmissions: sorted, } }) - .sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime()) - }, [submissions]) + .sort((a, b) => new Date(b.latestAt).getTime() - new Date(a.latestAt).getTime()) + }, [submissions, replyHistory]) - 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) - ) - }) + // ── All tags (for filter dropdown + autocomplete) ── + + const allTags = useMemo(() => { + const set = new Set() + for (const c of contacts) for (const t of c.tags) set.add(t) + return [...set].sort() + }, [contacts]) + + // ── Filtered list ── + + const filtered = useMemo(() => { + return contacts.filter(c => { + if (!showArchived && c.archived) return false + if (tagFilter && !c.tags.includes(tagFilter)) 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) || + c.tags.some(t => t.toLowerCase().includes(q)) + ) + }) + }, [contacts, showArchived, tagFilter, search]) + + // ── Edit helpers ── function startEdit(c: Contact) { - setEditingId(c.mainId) + setEditingKey(c.key) setEditName(c.name) setEditNotes(c.notes) + setEditTags([...c.tags]) + setEditTagInput('') + setMergePickerKey(null) + setMergeMsg('') } - 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() + function cancelEdit() { + setEditingKey(null) + setMergePickerKey(null) + setMergeMsg('') } + async function saveEdit(c: Contact) { + setEditSaving(true) + try { + await fetch(`/api/admin-contact-submissions/${encodeURIComponent(c.mainId)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ name: editName, notes: editNotes, tags: editTags }), + }) + setSubmissions(prev => prev.map(s => + s.id === c.mainId + ? { ...s, name: editName, notes: editNotes, tags: editTags } + : (s.email?.trim().toLowerCase() === c.key ? { ...s, name: editName } : s) + )) + setEditingKey(null) + } catch { /* silent */ } + setEditSaving(false) + } + + function addEditTag(tag: string) { + const t = tag.trim().slice(0, 50) + if (!t || editTags.includes(t)) return + setEditTags(prev => [...prev, t]) + setEditTagInput('') + } + + function removeEditTag(tag: string) { + setEditTags(prev => prev.filter(t => t !== tag)) + } + + // ── Delete ── + 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', - }) + fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, { method: 'DELETE', credentials: 'include' }) ) ) await reload() } - async function handleAdd(e: React.FormEvent) { - e.preventDefault() - setAddBusy(true) - setAddError('') + // ── Merge ── + + async function doMerge(keepContact: Contact, mergeContact: Contact) { + if (!confirm(`Merge "${mergeContact.name || mergeContact.email}" into "${keepContact.name || keepContact.email}"? All messages from ${mergeContact.email} will be reassigned to ${keepContact.email}.`)) return + setMergeBusy(true) try { + const res = await fetch('/api/admin-contacts/merge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ keepEmail: keepContact.email, mergeEmail: mergeContact.email }), + }) + if (res.ok) { + setMergeMsg('') + setMergePickerKey(null) + setEditingKey(null) + flash(`Merged ${mergeContact.email} into ${keepContact.email}.`) + await reload() + } else { + const d = await res.json() as { message?: string } + setMergeMsg(d.message ?? 'Merge failed.') + } + } catch { setMergeMsg('Network error.') } + setMergeBusy(false) + } + + // ── Add contact ── + + async function handleAdd(e: React.FormEvent) { + e.preventDefault(); setAddBusy(true); setAddError('') + try { + const tags = addTags.split(',').map(t => t.trim()).filter(Boolean) 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 }), + body: JSON.stringify({ name: addName, email: addEmail, notes: addNotes, tags }), }) - 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('') + const d = await res.json() as { ok?: boolean; message?: string } + if (!res.ok) { setAddError(d.message ?? 'Failed to add.'); setAddBusy(false); return } + setAddOpen(false); setAddName(''); setAddEmail(''); setAddNotes(''); setAddTags('') await reload() } catch { setAddError('Network error.') } setAddBusy(false) } + // ── CSV import ── + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = evt => { + const text = evt.target?.result as string + const rows = parseCSV(text) + setImportPreview({ rows, filename: file.name }) + } + reader.readAsText(file) + if (importFileRef.current) importFileRef.current.value = '' + } + + async function doImport() { + if (!importPreview) return + setImportBusy(true); setImportMsg('') + try { + const res = await fetch('/api/admin-contacts/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ rows: importPreview.rows }), + }) + const d = await res.json() as { ok?: boolean; created?: number; skipped?: number; message?: string } + if (!res.ok) { setImportMsg(d.message ?? 'Import failed.'); setImportBusy(false); return } + setImportMsg(`Imported ${d.created} contact${d.created !== 1 ? 's' : ''}${d.skipped ? `, skipped ${d.skipped}` : ''}.`) + setImportPreview(null) + await reload() + } catch { setImportMsg('Network error.') } + setImportBusy(false) + } + + // ── Flash ── + + function flash(msg: string) { + setFlashMsg(msg) + setTimeout(() => setFlashMsg(''), 3000) + } + + // ── Conversation history builder ── + + function buildHistory(c: Contact): ConversationItem[] { + const inbound: ConversationItem[] = c.allSubmissions.map(s => ({ + kind: 'inbound', + date: s.submittedAt, + name: s.name, + message: s.source === 'inbound-email' + ? s.message.replace(/^Subject:\s*.+\n+/m, '').trim().slice(0, 400) + : s.message.slice(0, 400), + source: s.source, + id: s.id, + })) + const outbound: ConversationItem[] = replyHistory + .filter(r => r.toEmail?.trim().toLowerCase() === c.key) + .map(r => ({ + kind: 'outbound', + date: r.scheduledAt ?? r.sentAt, + subject: r.subject, + preview: r.preview, + toEmail: r.toEmail, + })) + return [...inbound, ...outbound].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + } + + // ── Source badge ── + function sourceBadge(source: string | undefined) { if (source === 'manual') return manual if (source === 'inbound-email') return email @@ -276,212 +529,260 @@ function ContactsClient() { return form } - function fmtDate(iso: string) { - try { - return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) - } catch { return '—' } - } + // ── Render ── return (
+ {/* Header */}
Contacts {contacts.length}
- + + ✉ Email Calendar ← Admin
+ {/* Import preview */} + {importPreview && ( +
+
+ {importPreview.filename} — {importPreview.rows.length} row{importPreview.rows.length !== 1 ? 's' : ''} found + {importPreview.rows.length > 0 && ( + · columns: {Object.keys(importPreview.rows[0]).join(', ')} + )} +
+
+ + +
+ {importMsg &&

{importMsg}

} +
+ )} + {importMsg && !importPreview &&
{importMsg}
} + {flashMsg &&
{flashMsg}
} + + {/* Add contact form */} {addOpen && (

Add Contact

- - + + +
-