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 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.22",
|
"version": "1.1.23",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+5
-1
@@ -1113,7 +1113,11 @@ function sanitizeLoadedContactSubmissions(value) {
|
|||||||
snoozedUntil: typeof entry.snoozedUntil === 'string' && !isNaN(Date.parse(entry.snoozedUntil)) ? entry.snoozedUntil : null,
|
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(),
|
threadId: typeof entry.threadId === 'string' && entry.threadId.trim() ? entry.threadId.trim() : randomUUID(),
|
||||||
emailStatus: normalizeContactEmailStatus(entry.emailStatus, entry.subscribe === true),
|
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,
|
htmlBody: typeof entry.htmlBody === 'string' && entry.htmlBody.trim() ? entry.htmlBody : null,
|
||||||
inboundTo: typeof entry.inboundTo === 'string' ? entry.inboundTo : '',
|
inboundTo: typeof entry.inboundTo === 'string' ? entry.inboundTo : '',
|
||||||
messageId: typeof entry.messageId === 'string' ? entry.messageId : '',
|
messageId: typeof entry.messageId === 'string' ? entry.messageId : '',
|
||||||
|
|||||||
@@ -442,6 +442,9 @@ export function register(app) {
|
|||||||
if (typeof req.body?.starred === 'boolean') patch.starred = req.body.starred
|
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?.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 (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 ?? {})) {
|
if ('snoozedUntil' in (req.body ?? {})) {
|
||||||
const v = req.body.snoozedUntil
|
const v = req.body.snoozedUntil
|
||||||
patch.snoozedUntil = v === null ? null : (typeof v === 'string' && !isNaN(Date.parse(v)) ? v : undefined)
|
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 })
|
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) => {
|
app.get('/api/admin-contact-submissions/:id/attachments/:attachmentId', requireAdminAuth, (req, res) => {
|
||||||
const { id, attachmentId } = req.params
|
const { id, attachmentId } = req.params
|
||||||
const submission = state.contactSubmissions.find(s => s.id === id)
|
const submission = state.contactSubmissions.find(s => s.id === id)
|
||||||
|
|||||||
+254
@@ -10461,6 +10461,260 @@
|
|||||||
margin-top: 0.2rem;
|
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) ─────────────────────────────────────────────── */
|
/* ── Calendar Page (/calendar) ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
.cal-app {
|
.cal-app {
|
||||||
|
|||||||
@@ -657,8 +657,7 @@ function CalendarClient() {
|
|||||||
|
|
||||||
// ── Render chip ──
|
// ── Render chip ──
|
||||||
|
|
||||||
function renderEpChip(ep: PodcastChecklistEpisode, dk: string) {
|
function renderEpChip(ep: PodcastChecklistEpisode, _dk: string) {
|
||||||
const isScheduled = ep.datePublished === dk
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={ep.id}
|
key={ep.id}
|
||||||
|
|||||||
+522
-221
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -12,9 +12,23 @@ interface ContactSubmission {
|
|||||||
messageType: string
|
messageType: string
|
||||||
subscribe: boolean
|
subscribe: boolean
|
||||||
archived?: boolean
|
archived?: boolean
|
||||||
|
starred?: boolean
|
||||||
source?: string
|
source?: string
|
||||||
inboundTo?: string
|
inboundTo?: string
|
||||||
notes?: string
|
notes?: string
|
||||||
|
tags?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReplyHistoryItem {
|
||||||
|
id: string
|
||||||
|
submissionId: string
|
||||||
|
toEmail: string
|
||||||
|
toName: string
|
||||||
|
fromEmail: string
|
||||||
|
subject: string
|
||||||
|
preview: string
|
||||||
|
sentAt: string
|
||||||
|
scheduledAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Contact {
|
interface Contact {
|
||||||
@@ -22,18 +36,108 @@ interface Contact {
|
|||||||
email: string
|
email: string
|
||||||
name: string
|
name: string
|
||||||
source: string | undefined
|
source: string | undefined
|
||||||
inboundTo: string | undefined
|
|
||||||
subscribe: boolean
|
subscribe: boolean
|
||||||
archived: boolean
|
archived: boolean
|
||||||
submittedAt: string
|
firstContactAt: string // oldest submission date
|
||||||
|
latestAt: string // newest submission date
|
||||||
message: string
|
message: string
|
||||||
notes: string
|
notes: string
|
||||||
|
tags: string[]
|
||||||
|
lastContactedAt: string | null
|
||||||
submissionCount: number
|
submissionCount: number
|
||||||
mainId: string
|
mainId: string
|
||||||
allIds: string[]
|
allIds: string[]
|
||||||
|
allSubmissions: ContactSubmission[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Auth Shell ───────────────────────────────────────────────────────────────
|
type ConversationItem =
|
||||||
|
| { kind: 'inbound'; date: string; name: string; message: string; source?: string; id: string }
|
||||||
|
| { kind: 'outbound'; date: string; subject: string; preview: string; toEmail: string }
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TAG_PALETTE = [
|
||||||
|
'#1a3d2b', '#2b1a3d', '#3d1a1a', '#1a2b3d', '#3d2b1a',
|
||||||
|
'#1a3d3d', '#3d1a3d', '#2b3d1a', '#1a1a3d', '#3d3d1a',
|
||||||
|
]
|
||||||
|
|
||||||
|
function tagBg(tag: string): string {
|
||||||
|
let h = 0
|
||||||
|
for (const c of tag) h = (h * 31 + c.charCodeAt(0)) & 0x7fffffff
|
||||||
|
return TAG_PALETTE[h % TAG_PALETTE.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string | null | undefined) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
try { return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) }
|
||||||
|
catch { return '—' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtShort(iso: string | null | undefined) {
|
||||||
|
if (!iso) return ''
|
||||||
|
try {
|
||||||
|
const d = new Date(iso)
|
||||||
|
const now = new Date()
|
||||||
|
if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||||
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
} catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSV helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function csvField(v: string): string {
|
||||||
|
const s = String(v ?? '')
|
||||||
|
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportContactsCSV(contacts: Contact[]) {
|
||||||
|
const headers = ['name', 'email', 'notes', 'tags', 'source', 'subscribed', 'first_contact', 'last_contact', 'message_count']
|
||||||
|
const rows = contacts.map(c => [
|
||||||
|
c.name, c.email, c.notes,
|
||||||
|
c.tags.join(';'),
|
||||||
|
c.source ?? '',
|
||||||
|
c.subscribe ? 'yes' : 'no',
|
||||||
|
c.firstContactAt.slice(0, 10),
|
||||||
|
c.latestAt.slice(0, 10),
|
||||||
|
String(c.submissionCount),
|
||||||
|
])
|
||||||
|
const csv = [headers, ...rows].map(r => r.map(csvField).join(',')).join('\r\n')
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url; a.download = `contacts-${new Date().toISOString().slice(0, 10)}.csv`; a.click()
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCSVRow(line: string): string[] {
|
||||||
|
const result: string[] = []
|
||||||
|
let cur = '', inQ = false
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const ch = line[i]
|
||||||
|
if (ch === '"') {
|
||||||
|
if (inQ && line[i + 1] === '"') { cur += '"'; i++ }
|
||||||
|
else inQ = !inQ
|
||||||
|
} else if (ch === ',' && !inQ) { result.push(cur); cur = '' }
|
||||||
|
else cur += ch
|
||||||
|
}
|
||||||
|
result.push(cur)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCSV(text: string): Record<string, string>[] {
|
||||||
|
const lines = text.split(/\r?\n/).filter(l => l.trim())
|
||||||
|
if (lines.length < 2) return []
|
||||||
|
const headers = parseCSVRow(lines[0]).map(h => h.toLowerCase().trim().replace(/\s+/g, '_'))
|
||||||
|
return lines.slice(1)
|
||||||
|
.map(line => {
|
||||||
|
const vals = parseCSVRow(line)
|
||||||
|
return Object.fromEntries(headers.map((h, i) => [h, (vals[i] ?? '').trim()]))
|
||||||
|
})
|
||||||
|
.filter(r => Object.values(r).some(v => v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auth Shell ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ContactsShell() {
|
export default function ContactsShell() {
|
||||||
const [authState, setAuthState] = useState<'checking' | 'needs-password' | 'needs-totp' | 'ok'>('checking')
|
const [authState, setAuthState] = useState<'checking' | 'needs-password' | 'needs-totp' | 'ok'>('checking')
|
||||||
@@ -41,46 +145,31 @@ export default function ContactsShell() {
|
|||||||
const [totp, setTotp] = useState('')
|
const [totp, setTotp] = useState('')
|
||||||
const [authError, setAuthError] = useState('')
|
const [authError, setAuthError] = useState('')
|
||||||
const [authBusy, setAuthBusy] = useState(false)
|
const [authBusy, setAuthBusy] = useState(false)
|
||||||
|
const [pendingToken, setPendingToken] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/admin-auth/status', { credentials: 'include' })
|
fetch('/api/admin-auth/status', { credentials: 'include' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then((data: { authenticated?: boolean }) => {
|
.then((d: { authenticated?: boolean }) => setAuthState(d.authenticated ? 'ok' : 'needs-password'))
|
||||||
setAuthState(data.authenticated ? 'ok' : 'needs-password')
|
|
||||||
})
|
|
||||||
.catch(() => setAuthState('needs-password'))
|
.catch(() => setAuthState('needs-password'))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function handleLogin(e: React.FormEvent) {
|
async function handleLogin(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault(); setAuthBusy(true); setAuthError('')
|
||||||
setAuthBusy(true)
|
|
||||||
setAuthError('')
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin-auth/login', {
|
const res = await fetch('/api/admin-auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), credentials: 'include' })
|
||||||
method: 'POST',
|
const data = await res.json() as { ok?: boolean; totpRequired?: boolean; pendingToken?: string; message?: string }
|
||||||
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 (!res.ok) { setAuthError(data.message ?? 'Invalid password.'); setAuthBusy(false); return }
|
||||||
if (data.requiresTOTP) { setAuthState('needs-totp'); setAuthBusy(false); return }
|
if (data.totpRequired && data.pendingToken) { setPendingToken(data.pendingToken); setAuthState('needs-totp'); setAuthBusy(false); return }
|
||||||
setAuthState('ok')
|
setAuthState('ok')
|
||||||
} catch { setAuthError('Login failed.') }
|
} catch { setAuthError('Login failed.') }
|
||||||
setAuthBusy(false)
|
setAuthBusy(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTotp(e: React.FormEvent) {
|
async function handleTotp(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault(); setAuthBusy(true); setAuthError('')
|
||||||
setAuthBusy(true)
|
|
||||||
setAuthError('')
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin-auth/totp', {
|
const res = await fetch('/api/admin-auth/totp-verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pendingToken, code: totp }), credentials: 'include' })
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: totp }),
|
|
||||||
credentials: 'include',
|
|
||||||
})
|
|
||||||
const data = await res.json() as { ok?: boolean; message?: string }
|
const data = await res.json() as { ok?: boolean; message?: string }
|
||||||
if (!res.ok) { setAuthError(data.message ?? 'Invalid code.'); setAuthBusy(false); return }
|
if (!res.ok) { setAuthError(data.message ?? 'Invalid code.'); setAuthBusy(false); return }
|
||||||
setAuthState('ok')
|
setAuthState('ok')
|
||||||
@@ -88,80 +177,91 @@ export default function ContactsShell() {
|
|||||||
setAuthBusy(false)
|
setAuthBusy(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authState === 'checking') {
|
if (authState === 'checking') return <div className="em-auth-loading">Loading…</div>
|
||||||
return <div className="em-auth-loading">Loading…</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authState === 'needs-password') {
|
if (authState === 'needs-password') return (
|
||||||
return (
|
|
||||||
<div className="em-auth-wrap">
|
<div className="em-auth-wrap">
|
||||||
<form className="em-auth-form" onSubmit={handleLogin}>
|
<form className="em-auth-form" onSubmit={handleLogin}>
|
||||||
<h1 className="em-auth-title">Contacts</h1>
|
<h1 className="em-auth-title">Contacts</h1>
|
||||||
<label className="em-auth-label">Admin password
|
<label className="em-auth-label">Admin password<input type="password" className="em-auth-input" value={password} onChange={e => setPassword(e.target.value)} autoFocus /></label>
|
||||||
<input type="password" className="em-auth-input" value={password} onChange={e => setPassword(e.target.value)} autoFocus />
|
|
||||||
</label>
|
|
||||||
{authError && <p className="em-auth-error">{authError}</p>}
|
{authError && <p className="em-auth-error">{authError}</p>}
|
||||||
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>
|
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>{authBusy ? 'Signing in…' : 'Sign in'}</button>
|
||||||
{authBusy ? 'Signing in…' : 'Sign in'}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
if (authState === 'needs-totp') {
|
if (authState === 'needs-totp') return (
|
||||||
return (
|
|
||||||
<div className="em-auth-wrap">
|
<div className="em-auth-wrap">
|
||||||
<form className="em-auth-form" onSubmit={handleTotp}>
|
<form className="em-auth-form" onSubmit={handleTotp}>
|
||||||
<h1 className="em-auth-title">Two-factor code</h1>
|
<h1 className="em-auth-title">Two-factor code</h1>
|
||||||
<label className="em-auth-label">Authenticator code
|
<label className="em-auth-label">Authenticator code<input type="text" className="em-auth-input" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={totp} onChange={e => setTotp(e.target.value)} autoFocus /></label>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="em-auth-input"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
maxLength={6}
|
|
||||||
value={totp}
|
|
||||||
onChange={e => setTotp(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{authError && <p className="em-auth-error">{authError}</p>}
|
{authError && <p className="em-auth-error">{authError}</p>}
|
||||||
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>
|
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>{authBusy ? 'Verifying…' : 'Verify'}</button>
|
||||||
{authBusy ? 'Verifying…' : 'Verify'}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
return <ContactsClient />
|
return <ContactsClient />
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Contacts Client ──────────────────────────────────────────────────────────
|
// ── Contacts Client ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ContactsClient() {
|
function ContactsClient() {
|
||||||
const [submissions, setSubmissions] = useState<ContactSubmission[]>([])
|
const [submissions, setSubmissions] = useState<ContactSubmission[]>([])
|
||||||
|
const [replyHistory, setReplyHistory] = useState<ReplyHistoryItem[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
const [tagFilter, setTagFilter] = useState('')
|
||||||
const [showArchived, setShowArchived] = useState(false)
|
const [showArchived, setShowArchived] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
|
||||||
|
// Edit state
|
||||||
|
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||||
const [editName, setEditName] = useState('')
|
const [editName, setEditName] = useState('')
|
||||||
const [editNotes, setEditNotes] = useState('')
|
const [editNotes, setEditNotes] = useState('')
|
||||||
|
const [editTags, setEditTags] = useState<string[]>([])
|
||||||
|
const [editTagInput, setEditTagInput] = useState('')
|
||||||
const [editSaving, setEditSaving] = useState(false)
|
const [editSaving, setEditSaving] = useState(false)
|
||||||
|
|
||||||
|
// Merge state
|
||||||
|
const [mergePickerKey, setMergePickerKey] = useState<string | null>(null)
|
||||||
|
const [mergeSearch, setMergeSearch] = useState('')
|
||||||
|
const [mergeBusy, setMergeBusy] = useState(false)
|
||||||
|
const [mergeMsg, setMergeMsg] = useState('')
|
||||||
|
|
||||||
|
// History state
|
||||||
|
const [expandedHistoryKey, setExpandedHistoryKey] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Add contact
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
const [addName, setAddName] = useState('')
|
const [addName, setAddName] = useState('')
|
||||||
const [addEmail, setAddEmail] = useState('')
|
const [addEmail, setAddEmail] = useState('')
|
||||||
const [addNotes, setAddNotes] = useState('')
|
const [addNotes, setAddNotes] = useState('')
|
||||||
|
const [addTags, setAddTags] = useState('')
|
||||||
const [addBusy, setAddBusy] = useState(false)
|
const [addBusy, setAddBusy] = useState(false)
|
||||||
const [addError, setAddError] = useState('')
|
const [addError, setAddError] = useState('')
|
||||||
|
|
||||||
|
// CSV import
|
||||||
|
const [importBusy, setImportBusy] = useState(false)
|
||||||
|
const [importMsg, setImportMsg] = useState('')
|
||||||
|
const [importPreview, setImportPreview] = useState<{ rows: Record<string, string>[]; filename: string } | null>(null)
|
||||||
|
const importFileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Flash
|
||||||
|
const [flashMsg, setFlashMsg] = useState('')
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin-contact-submissions', { credentials: 'include' })
|
const [subRes, histRes] = await Promise.all([
|
||||||
if (res.ok) {
|
fetch('/api/admin-contact-submissions', { credentials: 'include' }),
|
||||||
const data = await res.json() as { submissions: ContactSubmission[] }
|
fetch('/api/admin-contact-reply-history', { credentials: 'include' }),
|
||||||
setSubmissions(data.submissions ?? [])
|
])
|
||||||
|
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 */ }
|
} catch { /* silent */ }
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -169,7 +269,10 @@ function ContactsClient() {
|
|||||||
|
|
||||||
useEffect(() => { reload() }, [reload])
|
useEffect(() => { reload() }, [reload])
|
||||||
|
|
||||||
|
// ── Derived contacts ──
|
||||||
|
|
||||||
const contacts: Contact[] = useMemo(() => {
|
const contacts: Contact[] = useMemo(() => {
|
||||||
|
// Group by email (lowercased), falling back to id
|
||||||
const grouped = new Map<string, ContactSubmission[]>()
|
const grouped = new Map<string, ContactSubmission[]>()
|
||||||
for (const s of submissions) {
|
for (const s of submissions) {
|
||||||
const key = s.email?.trim().toLowerCase() || s.id
|
const key = s.email?.trim().toLowerCase() || s.id
|
||||||
@@ -177,98 +280,248 @@ function ContactsClient() {
|
|||||||
arr.push(s)
|
arr.push(s)
|
||||||
grouped.set(key, arr)
|
grouped.set(key, arr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build last-contacted index from reply history
|
||||||
|
const lastContactedMap = new Map<string, string>()
|
||||||
|
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())
|
return Array.from(grouped.values())
|
||||||
.map(entries => {
|
.map(entries => {
|
||||||
const sorted = [...entries].sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
const sorted = [...entries].sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
||||||
const latest = sorted[0]
|
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 {
|
return {
|
||||||
key: latest.email?.trim().toLowerCase() || latest.id,
|
key: emailKey,
|
||||||
email: latest.email,
|
email: latest.email ?? '',
|
||||||
name: latest.name,
|
name: latest.name ?? '',
|
||||||
source: latest.source,
|
source: latest.source,
|
||||||
inboundTo: latest.inboundTo,
|
|
||||||
subscribe: sorted.some(s => s.subscribe),
|
subscribe: sorted.some(s => s.subscribe),
|
||||||
archived: sorted.every(s => s.archived === true),
|
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 || '',
|
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,
|
submissionCount: sorted.length,
|
||||||
mainId: latest.id,
|
mainId: latest.id,
|
||||||
allIds: sorted.map(s => s.id),
|
allIds: sorted.map(s => s.id),
|
||||||
|
allSubmissions: sorted,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
.sort((a, b) => new Date(b.latestAt).getTime() - new Date(a.latestAt).getTime())
|
||||||
}, [submissions])
|
}, [submissions, replyHistory])
|
||||||
|
|
||||||
const filtered = contacts.filter(c => {
|
// ── All tags (for filter dropdown + autocomplete) ──
|
||||||
|
|
||||||
|
const allTags = useMemo(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
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 (!showArchived && c.archived) return false
|
||||||
|
if (tagFilter && !c.tags.includes(tagFilter)) return false
|
||||||
if (!search.trim()) return true
|
if (!search.trim()) return true
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
return (
|
return (
|
||||||
c.name.toLowerCase().includes(q) ||
|
c.name.toLowerCase().includes(q) ||
|
||||||
c.email.toLowerCase().includes(q) ||
|
c.email.toLowerCase().includes(q) ||
|
||||||
c.message.toLowerCase().includes(q) ||
|
c.message.toLowerCase().includes(q) ||
|
||||||
c.notes.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) {
|
function startEdit(c: Contact) {
|
||||||
setEditingId(c.mainId)
|
setEditingKey(c.key)
|
||||||
setEditName(c.name)
|
setEditName(c.name)
|
||||||
setEditNotes(c.notes)
|
setEditNotes(c.notes)
|
||||||
|
setEditTags([...c.tags])
|
||||||
|
setEditTagInput('')
|
||||||
|
setMergePickerKey(null)
|
||||||
|
setMergeMsg('')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveEdit() {
|
function cancelEdit() {
|
||||||
if (!editingId) return
|
setEditingKey(null)
|
||||||
|
setMergePickerKey(null)
|
||||||
|
setMergeMsg('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(c: Contact) {
|
||||||
setEditSaving(true)
|
setEditSaving(true)
|
||||||
await fetch(`/api/admin-contact-submissions/${encodeURIComponent(editingId)}`, {
|
try {
|
||||||
|
await fetch(`/api/admin-contact-submissions/${encodeURIComponent(c.mainId)}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ name: editName, notes: editNotes }),
|
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)
|
setEditSaving(false)
|
||||||
setEditingId(null)
|
|
||||||
await reload()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
async function deleteContact(c: Contact) {
|
||||||
const label = c.name || c.email || 'this contact'
|
const label = c.name || c.email || 'this contact'
|
||||||
const plural = c.submissionCount > 1 ? `all ${c.submissionCount} submissions` : 'submission'
|
const plural = c.submissionCount > 1 ? `all ${c.submissionCount} submissions` : 'submission'
|
||||||
if (!confirm(`Delete ${plural} from ${label}?`)) return
|
if (!confirm(`Delete ${plural} from ${label}?`)) return
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
c.allIds.map(id =>
|
c.allIds.map(id =>
|
||||||
fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, {
|
fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, { method: 'DELETE', credentials: 'include' })
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'include',
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await reload()
|
await reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAdd(e: React.FormEvent) {
|
// ── Merge ──
|
||||||
e.preventDefault()
|
|
||||||
setAddBusy(true)
|
async function doMerge(keepContact: Contact, mergeContact: Contact) {
|
||||||
setAddError('')
|
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 {
|
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', {
|
const res = await fetch('/api/admin-contact-submissions/add', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
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 }
|
const d = await res.json() as { ok?: boolean; message?: string }
|
||||||
if (!res.ok) { setAddError(data.message ?? 'Failed to add contact.'); setAddBusy(false); return }
|
if (!res.ok) { setAddError(d.message ?? 'Failed to add.'); setAddBusy(false); return }
|
||||||
setAddOpen(false)
|
setAddOpen(false); setAddName(''); setAddEmail(''); setAddNotes(''); setAddTags('')
|
||||||
setAddName('')
|
|
||||||
setAddEmail('')
|
|
||||||
setAddNotes('')
|
|
||||||
await reload()
|
await reload()
|
||||||
} catch { setAddError('Network error.') }
|
} catch { setAddError('Network error.') }
|
||||||
setAddBusy(false)
|
setAddBusy(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CSV import ──
|
||||||
|
|
||||||
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
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) {
|
function sourceBadge(source: string | undefined) {
|
||||||
if (source === 'manual') return <span className="ct-badge ct-badge--manual">manual</span>
|
if (source === 'manual') return <span className="ct-badge ct-badge--manual">manual</span>
|
||||||
if (source === 'inbound-email') return <span className="ct-badge ct-badge--email">email</span>
|
if (source === 'inbound-email') return <span className="ct-badge ct-badge--email">email</span>
|
||||||
@@ -276,212 +529,260 @@ function ContactsClient() {
|
|||||||
return <span className="ct-badge ct-badge--form">form</span>
|
return <span className="ct-badge ct-badge--form">form</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDate(iso: string) {
|
// ── Render ──
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
||||||
} catch { return '—' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ct-app">
|
<div className="ct-app">
|
||||||
|
{/* Header */}
|
||||||
<header className="ct-header">
|
<header className="ct-header">
|
||||||
<div className="ct-header-brand">
|
<div className="ct-header-brand">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" />
|
||||||
<circle cx="9" cy="7" r="4" />
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
||||||
</svg>
|
</svg>
|
||||||
Contacts
|
Contacts
|
||||||
<span className="ct-header-count">{contacts.length}</span>
|
<span className="ct-header-count">{contacts.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="ct-header-actions">
|
<div className="ct-header-actions">
|
||||||
<button
|
<button type="button" className="em-btn em-btn--secondary em-btn--sm" onClick={() => { setAddOpen(o => !o); setAddError('') }}>
|
||||||
type="button"
|
|
||||||
className="em-btn em-btn--secondary em-btn--sm"
|
|
||||||
onClick={() => { setAddOpen(o => !o); setAddError('') }}
|
|
||||||
>
|
|
||||||
{addOpen ? 'Cancel' : '+ Add Contact'}
|
{addOpen ? 'Cancel' : '+ Add Contact'}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => exportContactsCSV(filtered)} title="Export visible contacts to CSV">
|
||||||
|
↓ Export CSV
|
||||||
|
</button>
|
||||||
|
<label className="em-btn em-btn--ghost em-btn--sm" style={{ cursor: 'pointer' }}>
|
||||||
|
↑ Import CSV
|
||||||
|
<input ref={importFileRef} type="file" accept=".csv,text/csv" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||||
|
</label>
|
||||||
<Link to="/email" className="em-btn em-btn--ghost em-btn--sm">✉ Email</Link>
|
<Link to="/email" className="em-btn em-btn--ghost em-btn--sm">✉ Email</Link>
|
||||||
<Link to="/calendar" className="em-btn em-btn--ghost em-btn--sm">Calendar</Link>
|
<Link to="/calendar" className="em-btn em-btn--ghost em-btn--sm">Calendar</Link>
|
||||||
<Link to="/admin" className="em-btn em-btn--ghost em-btn--sm">← Admin</Link>
|
<Link to="/admin" className="em-btn em-btn--ghost em-btn--sm">← Admin</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Import preview */}
|
||||||
|
{importPreview && (
|
||||||
|
<div className="ct-import-banner">
|
||||||
|
<div className="ct-import-info">
|
||||||
|
<strong>{importPreview.filename}</strong> — {importPreview.rows.length} row{importPreview.rows.length !== 1 ? 's' : ''} found
|
||||||
|
{importPreview.rows.length > 0 && (
|
||||||
|
<span className="ct-import-cols"> · columns: {Object.keys(importPreview.rows[0]).join(', ')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ct-import-actions">
|
||||||
|
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={doImport} disabled={importBusy || importPreview.rows.length === 0}>
|
||||||
|
{importBusy ? 'Importing…' : `Import ${importPreview.rows.length} contacts`}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setImportPreview(null); setImportMsg('') }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
{importMsg && <p className="ct-import-msg">{importMsg}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{importMsg && !importPreview && <div className="ct-flash">{importMsg}</div>}
|
||||||
|
{flashMsg && <div className="ct-flash">{flashMsg}</div>}
|
||||||
|
|
||||||
|
{/* Add contact form */}
|
||||||
{addOpen && (
|
{addOpen && (
|
||||||
<div className="ct-add-banner">
|
<div className="ct-add-banner">
|
||||||
<form className="ct-add-form" onSubmit={handleAdd}>
|
<form className="ct-add-form" onSubmit={handleAdd}>
|
||||||
<h3 className="ct-add-title">Add Contact</h3>
|
<h3 className="ct-add-title">Add Contact</h3>
|
||||||
<div className="ct-add-row">
|
<div className="ct-add-row">
|
||||||
<label className="ct-add-label">
|
<label className="ct-add-label">Name<input className="ct-input" type="text" placeholder="Full name" value={addName} onChange={e => setAddName(e.target.value)} autoFocus /></label>
|
||||||
Name
|
<label className="ct-add-label">Email<input className="ct-input" type="email" placeholder="email@example.com" value={addEmail} onChange={e => setAddEmail(e.target.value)} /></label>
|
||||||
<input
|
<label className="ct-add-label">Tags (comma-separated)<input className="ct-input" type="text" placeholder="listener, partner" value={addTags} onChange={e => setAddTags(e.target.value)} /></label>
|
||||||
className="ct-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Full name"
|
|
||||||
value={addName}
|
|
||||||
onChange={e => setAddName(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="ct-add-label">
|
|
||||||
Email
|
|
||||||
<input
|
|
||||||
className="ct-input"
|
|
||||||
type="email"
|
|
||||||
placeholder="email@example.com"
|
|
||||||
value={addEmail}
|
|
||||||
onChange={e => setAddEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<label className="ct-add-label ct-add-label--full">
|
<label className="ct-add-label ct-add-label--full">Notes<textarea className="ct-input ct-notes-input" placeholder="Notes (optional)" value={addNotes} onChange={e => setAddNotes(e.target.value)} rows={2} /></label>
|
||||||
Notes
|
|
||||||
<textarea
|
|
||||||
className="ct-input ct-notes-input"
|
|
||||||
placeholder="Notes (optional)"
|
|
||||||
value={addNotes}
|
|
||||||
onChange={e => setAddNotes(e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{addError && <p className="ct-error">{addError}</p>}
|
{addError && <p className="ct-error">{addError}</p>}
|
||||||
<div className="ct-add-actions">
|
<div className="ct-add-actions">
|
||||||
<button type="submit" className="em-btn em-btn--primary em-btn--sm" disabled={addBusy}>
|
<button type="submit" className="em-btn em-btn--primary em-btn--sm" disabled={addBusy}>{addBusy ? 'Adding…' : 'Add Contact'}</button>
|
||||||
{addBusy ? 'Adding…' : 'Add Contact'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
<div className="ct-toolbar">
|
<div className="ct-toolbar">
|
||||||
<input
|
<input type="search" className="ct-search" placeholder="Search name, email, notes, tags…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
type="search"
|
<select className="ct-tag-filter" value={tagFilter} onChange={e => setTagFilter(e.target.value)}>
|
||||||
className="ct-search"
|
<option value="">All tags</option>
|
||||||
placeholder="Search name, email, message, or notes…"
|
{allTags.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
value={search}
|
</select>
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
<label className="ct-archived-toggle">
|
<label className="ct-archived-toggle">
|
||||||
<input
|
<input type="checkbox" checked={showArchived} onChange={e => setShowArchived(e.target.checked)} />
|
||||||
type="checkbox"
|
|
||||||
checked={showArchived}
|
|
||||||
onChange={e => setShowArchived(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Show archived
|
Show archived
|
||||||
</label>
|
</label>
|
||||||
<span className="ct-count-label">
|
<span className="ct-count-label">{filtered.length} contact{filtered.length !== 1 ? 's' : ''}</span>
|
||||||
{filtered.length} contact{filtered.length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Contact list */}
|
||||||
<div className="ct-list">
|
<div className="ct-list">
|
||||||
{loading && <p className="ct-empty">Loading contacts…</p>}
|
{loading && <p className="ct-empty">Loading contacts…</p>}
|
||||||
{!loading && filtered.length === 0 && (
|
{!loading && filtered.length === 0 && (
|
||||||
<p className="ct-empty">
|
<p className="ct-empty">{search || tagFilter ? 'No contacts match.' : 'No contacts yet.'}</p>
|
||||||
{search ? 'No contacts match your search.' : 'No contacts yet. Add one above.'}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filtered.map(c => (
|
{filtered.map(c => {
|
||||||
|
const isEditing = editingKey === c.key
|
||||||
|
const historyOpen = expandedHistoryKey === c.key
|
||||||
|
const history = historyOpen ? buildHistory(c) : []
|
||||||
|
const isMergeTarget = mergePickerKey === c.key
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={c.mainId} className={`ct-card${c.archived ? ' ct-card--archived' : ''}`}>
|
<div key={c.mainId} className={`ct-card${c.archived ? ' ct-card--archived' : ''}`}>
|
||||||
<div className="ct-avatar" aria-hidden="true">
|
<div className="ct-avatar" aria-hidden="true">
|
||||||
{(c.name || c.email || '?').charAt(0).toUpperCase()}
|
{(c.name || c.email || '?').charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ct-card-body">
|
<div className="ct-card-body">
|
||||||
{editingId === c.mainId ? (
|
{isEditing ? (
|
||||||
|
/* ── Edit mode ── */
|
||||||
<div className="ct-edit-form">
|
<div className="ct-edit-form">
|
||||||
<div className="ct-edit-row">
|
<div className="ct-edit-row">
|
||||||
<label className="ct-edit-label">
|
<label className="ct-edit-label">Name<input className="ct-input" type="text" value={editName} onChange={e => setEditName(e.target.value)} autoFocus /></label>
|
||||||
Name
|
|
||||||
<input
|
|
||||||
className="ct-input"
|
|
||||||
type="text"
|
|
||||||
value={editName}
|
|
||||||
onChange={e => setEditName(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<label className="ct-edit-label">
|
<label className="ct-edit-label">
|
||||||
Notes
|
Tags
|
||||||
<textarea
|
<div className="ct-tag-editor">
|
||||||
className="ct-input ct-notes-input"
|
{editTags.map(t => (
|
||||||
rows={3}
|
<span key={t} className="ct-tag" style={{ background: tagBg(t) }}>
|
||||||
value={editNotes}
|
{t}
|
||||||
onChange={e => setEditNotes(e.target.value)}
|
<button type="button" className="ct-tag-remove" onClick={() => removeEditTag(t)} aria-label={`Remove ${t}`}>×</button>
|
||||||
placeholder="Add a note about this contact…"
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
className="ct-tag-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Add tag…"
|
||||||
|
value={editTagInput}
|
||||||
|
list="ct-tag-suggestions"
|
||||||
|
onChange={e => setEditTagInput(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addEditTag(editTagInput) }
|
||||||
|
else if (e.key === 'Backspace' && !editTagInput && editTags.length) removeEditTag(editTags[editTags.length - 1])
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
<datalist id="ct-tag-suggestions">
|
||||||
|
{allTags.filter(t => !editTags.includes(t)).map(t => <option key={t} value={t} />)}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="ct-edit-label">Notes<textarea className="ct-input ct-notes-input" rows={3} value={editNotes} onChange={e => setEditNotes(e.target.value)} placeholder="Add a note…" /></label>
|
||||||
|
|
||||||
|
{/* Merge picker */}
|
||||||
|
{isMergeTarget && (
|
||||||
|
<div className="ct-merge-picker">
|
||||||
|
<p className="ct-merge-label">Merge another contact into <strong>{c.name || c.email}</strong>:</p>
|
||||||
|
<input className="ct-input ct-merge-search" type="search" placeholder="Search contacts to merge…" value={mergeSearch} autoFocus onChange={e => setMergeSearch(e.target.value)} />
|
||||||
|
<div className="ct-merge-list">
|
||||||
|
{contacts
|
||||||
|
.filter(other =>
|
||||||
|
other.key !== c.key &&
|
||||||
|
(!mergeSearch || other.name.toLowerCase().includes(mergeSearch.toLowerCase()) || other.email.toLowerCase().includes(mergeSearch.toLowerCase()))
|
||||||
|
)
|
||||||
|
.slice(0, 20)
|
||||||
|
.map(other => (
|
||||||
|
<button key={other.key} type="button" className="ct-merge-option" onClick={() => doMerge(c, other)} disabled={mergeBusy}>
|
||||||
|
<span className="ct-merge-name">{other.name || <em>No name</em>}</span>
|
||||||
|
<span className="ct-merge-email">{other.email}</span>
|
||||||
|
<span className="ct-merge-count">{other.submissionCount} msg{other.submissionCount !== 1 ? 's' : ''}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{mergeMsg && <p className="ct-error">{mergeMsg}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="ct-edit-actions">
|
<div className="ct-edit-actions">
|
||||||
<button
|
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={() => saveEdit(c)} disabled={editSaving}>{editSaving ? 'Saving…' : 'Save'}</button>
|
||||||
type="button"
|
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setMergePickerKey(prev => prev === c.key ? null : c.key); setMergeSearch(''); setMergeMsg('') }}>
|
||||||
className="em-btn em-btn--primary em-btn--sm"
|
{isMergeTarget ? 'Cancel merge' : 'Merge with…'}
|
||||||
onClick={saveEdit}
|
|
||||||
disabled={editSaving}
|
|
||||||
>
|
|
||||||
{editSaving ? 'Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="em-btn em-btn--ghost em-btn--sm"
|
|
||||||
onClick={() => setEditingId(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={cancelEdit}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
/* ── Display mode ── */
|
||||||
<>
|
<>
|
||||||
<div className="ct-card-top">
|
<div className="ct-card-top">
|
||||||
<span className="ct-card-name">{c.name || <em>No name</em>}</span>
|
<span className="ct-card-name">{c.name || <em>No name</em>}</span>
|
||||||
{c.email && (
|
{c.email && <a className="ct-card-email" href={`mailto:${c.email}`}>{c.email}</a>}
|
||||||
<a className="ct-card-email" href={`mailto:${c.email}`}>{c.email}</a>
|
|
||||||
)}
|
|
||||||
{sourceBadge(c.source)}
|
{sourceBadge(c.source)}
|
||||||
{c.subscribe && <span className="ct-badge ct-badge--sub">subscriber</span>}
|
{c.subscribe && <span className="ct-badge ct-badge--sub">subscriber</span>}
|
||||||
{c.submissionCount > 1 && (
|
{c.submissionCount > 1 && <span className="ct-count-badge">{c.submissionCount}</span>}
|
||||||
<span className="ct-count-badge">{c.submissionCount}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className={`ct-card-notes${!c.notes ? ' ct-card-notes--empty' : ''}`}>
|
|
||||||
{c.notes || 'No notes — click Edit to add'}
|
{/* Tags row */}
|
||||||
</p>
|
{c.tags.length > 0 && (
|
||||||
<div className="ct-card-bottom">
|
<div className="ct-tags-row">
|
||||||
<span className="ct-card-date">{fmtDate(c.submittedAt)}</span>
|
{c.tags.map(t => (
|
||||||
{c.message && (
|
<span key={t} className="ct-tag" style={{ background: tagBg(t) }}>{t}</span>
|
||||||
<span className="ct-card-preview">
|
))}
|
||||||
{c.message.length > 90 ? c.message.slice(0, 90) + '…' : c.message}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ct-card-meta-row">
|
||||||
|
<span className="ct-card-date">
|
||||||
|
First: {fmtDate(c.firstContactAt)}
|
||||||
|
{c.lastContactedAt && <> · <span className="ct-last-contacted">Last replied: {fmtShort(c.lastContactedAt)}</span></>}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{c.notes && <p className="ct-card-notes">{c.notes}</p>}
|
||||||
|
|
||||||
|
{c.message && (
|
||||||
|
<p className="ct-card-preview">
|
||||||
|
{c.message.length > 100 ? c.message.slice(0, 100) + '…' : c.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editingId !== c.mainId && (
|
{/* Actions */}
|
||||||
|
{!isEditing && (
|
||||||
<div className="ct-card-actions">
|
<div className="ct-card-actions">
|
||||||
|
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => startEdit(c)}>Edit</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="em-btn em-btn--ghost em-btn--sm"
|
className={`em-btn em-btn--sm ${historyOpen ? 'em-btn--secondary' : 'em-btn--ghost'}`}
|
||||||
onClick={() => startEdit(c)}
|
onClick={() => setExpandedHistoryKey(prev => prev === c.key ? null : c.key)}
|
||||||
>
|
>
|
||||||
Edit
|
History{c.submissionCount > 1 || replyHistory.some(r => r.toEmail?.trim().toLowerCase() === c.key) ? ` (${c.submissionCount})` : ''}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="em-btn em-btn--danger em-btn--sm"
|
|
||||||
onClick={() => deleteContact(c)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="em-btn em-btn--danger em-btn--sm" onClick={() => deleteContact(c)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Conversation history panel */}
|
||||||
|
{historyOpen && !isEditing && (
|
||||||
|
<div className="ct-history-panel">
|
||||||
|
{history.length === 0 && <p className="ct-history-empty">No conversation history.</p>}
|
||||||
|
{history.map((item, i) => (
|
||||||
|
item.kind === 'inbound' ? (
|
||||||
|
<div key={item.id || i} className="ct-history-item ct-history-item--in">
|
||||||
|
<div className="ct-history-meta">
|
||||||
|
<span className="ct-history-who">{item.name}</span>
|
||||||
|
<span className="ct-history-date">{fmtShort(item.date)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="ct-history-body">{item.message || '(no message body)'}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={i} className="ct-history-item ct-history-item--out">
|
||||||
|
<div className="ct-history-meta">
|
||||||
|
<span className="ct-history-who">You → {item.toEmail}</span>
|
||||||
|
<span className="ct-history-date">{fmtShort(item.date)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ct-history-subject">{item.subject}</div>
|
||||||
|
<p className="ct-history-body">{item.preview}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user