From a35e04320abcb4820545480ffe6e1f79c6ed6292 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 20 Aug 2026 09:30:19 -0400 Subject: [PATCH] Remove production checklist and email/calendar/contacts app; v1.1.37 Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- src/AdminPage.tsx | 459 +------------ src/App.tsx | 6 - src/CalendarPage.tsx | 1080 ----------------------------- src/ContactsPage.tsx | 897 ------------------------ src/EmailPage.tsx | 1557 ------------------------------------------ 6 files changed, 6 insertions(+), 3995 deletions(-) delete mode 100644 src/CalendarPage.tsx delete mode 100644 src/ContactsPage.tsx delete mode 100644 src/EmailPage.tsx diff --git a/package.json b/package.json index debb8e8..4d7425d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.36", + "version": "1.1.37", "type": "module", "scripts": { "dev": "vite", diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index ee99859..bc79e04 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -693,34 +693,12 @@ interface Subscriber { source: 'contact-form' | 'download' } -type ChecklistPhase = 'pre' | 'post' - -interface PodcastChecklistTask { - id: string - label: string - phase: ChecklistPhase -} - -interface PodcastChecklistEpisode { - id: string - series: string - episodeNumber: number | null - title: string - datePublished: string - expanded: boolean - tasks: Record -} - -interface PodcastChecklistData { - tasks: PodcastChecklistTask[] - episodes: PodcastChecklistEpisode[] -} type StringField = Exclude type AdminView = | 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact' - | 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' + | 'podcast' | 'current-series' | 'episode-highlights' | 'downloads' | 'custom-links' | 'content-blocks' | 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes' | 'subscribers' | 'study-users' | 'email-templates' @@ -837,7 +815,7 @@ const ADMIN_SECTION_LINKS: Partial> = { ], } -type PodcastTab = 'current-series' | 'episode-highlights' | 'finished-books' | 'podcast-checklist' | 'episode-scripts' | 'rss-feed' +type PodcastTab = 'current-series' | 'episode-highlights' | 'finished-books' | 'episode-scripts' | 'rss-feed' type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' | 'email-templates' @@ -1203,10 +1181,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [rssEpisodes, setRssEpisodes] = useState>([]) - const [podcastChecklist, setPodcastChecklist] = useState({ tasks: [], episodes: [] }) - const [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') - const [podcastChecklistMsg, setPodcastChecklistMsg] = useState('') - const [dashboardNow, setDashboardNow] = useState(() => new Date()) +const [dashboardNow, setDashboardNow] = useState(() => new Date()) const [manualQuestion, setManualQuestion] = useState({ firstName: '', email: '', @@ -1423,16 +1398,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { .then(r => (r.ok ? r.json() : Promise.reject())) .then(data => setDownloadStats((data as { counts: Record }).counts ?? {})) .catch(() => {}) - - fetch('/api/admin-podcast-checklist') - .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load podcast checklist')))) - .then(data => { - const checklist = (data as { checklist?: PodcastChecklistData }).checklist - if (checklist?.tasks && checklist?.episodes) { - setPodcastChecklist(checklist) - } - }) - .catch(() => {}) }, []) useEffect(() => { @@ -1741,150 +1706,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { setForm(f => ({ ...f, podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).filter(link => link.id !== id) })) } - function addChecklistTask(phase: ChecklistPhase) { - const id = `task-${Date.now().toString(36)}` - setPodcastChecklist(prev => ({ - tasks: [...prev.tasks, { id, label: '', phase }], - episodes: prev.episodes.map(episode => ({ - ...episode, - tasks: { - ...episode.tasks, - [id]: false, - }, - })), - })) - } - - function updateChecklistTask(id: string, field: 'label' | 'phase', value: string) { - setPodcastChecklist(prev => ({ - ...prev, - tasks: prev.tasks.map(task => task.id === id - ? { - ...task, - [field]: field === 'phase' ? (value === 'post' ? 'post' : 'pre') : value, - } - : task), - })) - } - - function removeChecklistTask(id: string) { - setPodcastChecklist(prev => ({ - tasks: prev.tasks.filter(task => task.id !== id), - episodes: prev.episodes.map(episode => { - const nextTasks = { ...episode.tasks } - delete nextTasks[id] - return { - ...episode, - tasks: nextTasks, - } - }), - })) - } - - function addChecklistEpisode() { - const id = `episode-${Date.now().toString(36)}` - setPodcastChecklist(prev => ({ - ...prev, - episodes: [ - ...prev.episodes, - { - id, - series: 'Colossians', - episodeNumber: null, - title: '', - datePublished: '', - expanded: false, - tasks: Object.fromEntries(prev.tasks.map(task => [task.id, false])), - }, - ], - })) - } - - function updateChecklistEpisode(id: string, field: 'series' | 'episodeNumber' | 'title' | 'datePublished', value: string) { - setPodcastChecklist(prev => ({ - ...prev, - episodes: prev.episodes.map(episode => { - if (episode.id !== id) return episode - if (field === 'episodeNumber') { - const parsed = Number.parseInt(value, 10) - return { - ...episode, - episodeNumber: Number.isNaN(parsed) ? null : parsed, - } - } - return { - ...episode, - [field]: value, - } - }), - })) - } - - function toggleChecklistEpisodeTask(episodeId: string, taskId: string) { - setPodcastChecklist(prev => ({ - ...prev, - episodes: prev.episodes.map(episode => { - if (episode.id !== episodeId) return episode - return { - ...episode, - tasks: { - ...episode.tasks, - [taskId]: !episode.tasks[taskId], - }, - } - }), - })) - } - - function resetChecklistEpisode(episodeId: string) { - setPodcastChecklist(prev => ({ - ...prev, - episodes: prev.episodes.map(episode => { - if (episode.id !== episodeId) return episode - return { - ...episode, - datePublished: '', - tasks: Object.fromEntries(prev.tasks.map(task => [task.id, false])), - } - }), - })) - } - - function removeChecklistEpisode(id: string) { - setPodcastChecklist(prev => ({ - ...prev, - episodes: prev.episodes.filter(episode => episode.id !== id), - })) - } - - async function handleSavePodcastChecklist() { - setPodcastChecklistStatus('saving') - setPodcastChecklistMsg('') - try { - const res = await fetch('/api/admin-podcast-checklist', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checklist: podcastChecklist }), - }) - - if (!res.ok) { - const data = await res.json().catch(() => ({})) as { message?: string } - throw new Error(data.message ?? 'Failed to save checklist') - } - - const data = await res.json() as { checklist?: PodcastChecklistData } - if (data.checklist?.tasks && data.checklist?.episodes) { - setPodcastChecklist(data.checklist) - } - setPodcastChecklistStatus('saved') - setTimeout(() => setPodcastChecklistStatus('idle'), 3000) - } catch (err) { - setPodcastChecklistMsg(err instanceof Error ? err.message : 'Failed to save checklist') - setPodcastChecklistStatus('error') - } - } - - async function handleAssetUpload(event: ChangeEvent) { +async function handleAssetUpload(event: ChangeEvent) { const file = event.target.files?.[0] if (!file) return @@ -2773,67 +2595,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { questionPage * QUESTION_PAGE_SIZE, (questionPage + 1) * QUESTION_PAGE_SIZE, ) - const checklistTaskOrder = [ - 'verify_script', - 'read_script', - 'record', - 'mix', - 'edit', - 'video_script', - 'post_spotify', - 'update_website', - 'send_email', - ] - const checklistTaskOrderIndex = new Map(checklistTaskOrder.map((id, index) => [id, index])) - const checklistTasksSorted = [...podcastChecklist.tasks].sort((a, b) => { - const aIndex = checklistTaskOrderIndex.get(a.id) - const bIndex = checklistTaskOrderIndex.get(b.id) - const aKnown = typeof aIndex === 'number' - const bKnown = typeof bIndex === 'number' - - if (aKnown && bKnown) return aIndex - bIndex - if (aKnown && !bKnown) return -1 - if (!aKnown && bKnown) return 1 - return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) - }) - const checklistPreTasks = checklistTasksSorted.filter(task => task.phase === 'pre') - const checklistPostTasks = checklistTasksSorted.filter(task => task.phase === 'post') - const checklistEpisodesSorted = [...podcastChecklist.episodes].sort((a, b) => { - const isDraft = (episode: PodcastChecklistEpisode) => { - const hasNumber = episode.episodeNumber !== null - const hasTitle = Boolean(episode.title?.trim()) - const hasDate = Boolean(episode.datePublished?.trim()) - return !hasNumber && !hasTitle && !hasDate - } - - const aDraft = isDraft(a) - const bDraft = isDraft(b) - if (aDraft && !bDraft) return -1 - if (!aDraft && bDraft) return 1 - - const seriesOrder = (series: string) => { - const key = series.trim().toLowerCase() - if (key === 'titus') return 0 - if (key === 'colossians') return 1 - return 2 - } - - const bySeries = seriesOrder(a.series) - seriesOrder(b.series) - if (bySeries !== 0) return bySeries - - const nameSort = a.series.localeCompare(b.series, undefined, { sensitivity: 'base' }) - if (nameSort !== 0) return nameSort - - const aNum = a.episodeNumber - const bNum = b.episodeNumber - if (aNum === null && bNum === null) return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }) - if (aNum === null) return 1 - if (bNum === null) return -1 - if (aNum !== bNum) return aNum - bNum - - return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }) - }) - function renderSaveStatus() { return ( <> @@ -2843,15 +2604,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { ) } - function renderPodcastChecklistStatus() { - return ( - <> - {podcastChecklistStatus === 'saved' &&

✓ Checklist saved.

} - {podcastChecklistStatus === 'error' &&

✗ {podcastChecklistMsg}

} - - ) - } - return (
{/* ── Top bar ── */} @@ -3535,8 +3287,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { - - +
@@ -3738,206 +3489,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { )} - {/* PODCAST CHECKLIST */} - {(adminView === 'podcast-checklist' || (adminView === 'podcast' && podcastTab === 'podcast-checklist')) && ( -
-
-

Podcast Production Checklist

-

Track production progress for each episode. Add as many tasks and episodes as you need, then save.

-
- -
-
-

Total Episodes

-

{podcastChecklist.episodes.length}

-
-
-

Pre-Publish Tasks

-

{checklistPreTasks.length}

-
-
-

Post-Publish Tasks

-

{checklistPostTasks.length}

-
-
- - -
- - -
- - {podcastChecklist.tasks.length === 0 && ( -

No tasks yet. Add a pre-publish or post-publish task above.

- )} - - {checklistTasksSorted.map(task => ( -
-
-
- - updateChecklistTask(task.id, 'label', e.target.value)} - placeholder="e.g. Upload transcript" - /> -
-
- - -
-
- -
- ))} -
- -
-
-
Episodes
-
- - -
-
- - {podcastChecklist.episodes.length === 0 && ( -

No episodes yet. Add one above to start tracking progress.

- )} - - {checklistEpisodesSorted.map(episode => { - const doneCount = checklistTasksSorted.reduce((count, task) => count + (episode.tasks[task.id] ? 1 : 0), 0) - const totalCount = checklistTasksSorted.length - const nextTask = checklistTasksSorted.find(task => !episode.tasks[task.id]) - const episodeLabelParts = [episode.series?.trim()] - if (episode.episodeNumber !== null) { - episodeLabelParts.push(String(episode.episodeNumber)) - } - if (episode.title?.trim()) { - episodeLabelParts.push(episode.title.trim()) - } - const episodeLabel = episodeLabelParts.filter(Boolean).join(' - ') || 'Untitled' - - return ( - 0 - ? `${doneCount}/${totalCount} tasks completed • Next: ${nextTask?.label ?? 'All done'}` - : 'No tasks assigned yet'} - > -
-
-
- - updateChecklistEpisode(episode.id, 'series', e.target.value)} - placeholder="Colossians" - /> -
-
- - updateChecklistEpisode(episode.id, 'episodeNumber', e.target.value)} - min={0} - /> -
-
- - updateChecklistEpisode(episode.id, 'title', e.target.value)} - placeholder="Grace that Trains Us" - /> -
-
- - updateChecklistEpisode(episode.id, 'datePublished', e.target.value)} - /> -
-
-
- - {checklistPreTasks.length > 0 && ( -
-
-
Pre-Publish Tasks
-
-
- {checklistPreTasks.map(task => ( - - ))} -
-
- )} - - {checklistPostTasks.length > 0 && ( -
-
-
Post-Publish Tasks
-
-
- {checklistPostTasks.map(task => ( - - ))} -
-
- )} - -
- - -
-
- ) - })} -
- - {renderPodcastChecklistStatus()} -
- )} - {/* DOWNLOADS */} {adminView === 'downloads' && (
diff --git a/src/App.tsx b/src/App.tsx index 6d03968..deb6cf8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,9 +2,6 @@ import { useState, useEffect, useRef } from 'react' 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' @@ -2497,9 +2494,6 @@ export default function App() { } /> } /> } /> - } /> - } /> - } /> } /> - reminderDays?: number - reminderSentAt?: string - productionStatus?: ProductionStatus -} - -type RecurrenceFreq = 'none' | 'weekly' | 'biweekly' | 'monthly' - -interface Recurrence { - freq: RecurrenceFreq - until: string | null -} - -type CalendarEventType = 'general' | 'recording' | 'social' | 'task' - -interface CalendarEvent { - id: string - type: CalendarEventType - title: string - date: string - startTime?: string - notes: string - completed: boolean - reminderDays: number - reminderSentAt?: string - recurrence?: Recurrence - createdAt: string - recurrenceOf?: string // runtime only: id of base event if this is an expanded instance -} - -interface PodcastChecklistData { - tasks: PodcastChecklistTask[] - episodes: PodcastChecklistEpisode[] -} - -type ViewMode = 'month' | 'week' | 'day' - -// ── Constants ───────────────────────────────────────────────────────────────── - -const REMINDER_OPTIONS = [ - { value: '0', label: 'No reminder' }, - { value: '1', label: '1 day before' }, - { value: '2', label: '2 days before' }, - { value: '3', label: '3 days before' }, - { value: '7', label: '1 week before' }, - { value: '14', label: '2 weeks before' }, -] - -const EVENT_TYPE_OPTIONS: { value: CalendarEventType; label: string; icon: string }[] = [ - { value: 'general', label: 'General', icon: '📌' }, - { value: 'recording', label: 'Recording', icon: '🎙️' }, - { value: 'social', label: 'Social', icon: '📱' }, - { value: 'task', label: 'Task', icon: '✅' }, -] - -const RECURRENCE_OPTIONS: { value: RecurrenceFreq; label: string }[] = [ - { value: 'none', label: 'Does not repeat' }, - { value: 'weekly', label: 'Weekly' }, - { value: 'biweekly', label: 'Every 2 weeks' }, - { value: 'monthly', label: 'Monthly' }, -] - -const MONTH_NAMES = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December', -] - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function toDateKey(date: Date) { - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` -} - -function getMondayOf(date: Date): Date { - const d = new Date(date) - const dow = d.getDay() - const diff = dow === 0 ? -6 : 1 - dow - d.setDate(d.getDate() + diff) - return d -} - -function addDays(date: Date, n: number): Date { - const d = new Date(date) - d.setDate(d.getDate() + n) - return d -} - -function addMonths(date: Date, n: number): Date { - const d = new Date(date) - d.setMonth(d.getMonth() + n) - return d -} - -function formatTimeDisplay(t: string | undefined): string { - if (!t) return '' - const [h, m] = t.split(':').map(Number) - if (isNaN(h)) return '' - const ampm = h >= 12 ? 'PM' : 'AM' - const h12 = h % 12 || 12 - return `${h12}:${String(m).padStart(2, '0')} ${ampm}` -} - -function expandRecurring(events: CalendarEvent[], firstKey: string, lastKey: string): CalendarEvent[] { - const result: CalendarEvent[] = [] - for (const ev of events) { - // always include base event if it falls in range - if (ev.date >= firstKey && ev.date <= lastKey) result.push(ev) - - if (!ev.recurrence || ev.recurrence.freq === 'none') continue - - const base = new Date(ev.date + 'T12:00:00') - const untilDate = ev.recurrence.until - ? new Date(ev.recurrence.until + 'T23:59:59') - : new Date(base.getFullYear() + 2, base.getMonth(), base.getDate()) // 2-year horizon - - let d = new Date(base) - for (let i = 0; i < 1000; i++) { - if (ev.recurrence.freq === 'weekly') d = addDays(d, 7) - else if (ev.recurrence.freq === 'biweekly') d = addDays(d, 14) - else d = new Date(d.getFullYear(), d.getMonth() + 1, d.getDate()) - - if (d > untilDate) break - const dk = toDateKey(d) - if (dk > lastKey) break // past visible range - if (dk === ev.date) continue // skip base date duplicate - if (dk < firstKey) continue // before visible range, keep iterating - - // Don't add if it's the same as a date that's already included as a non-recurring event - result.push({ ...ev, id: `${ev.id}:${dk}`, date: dk, recurrenceOf: ev.id }) - } - } - return result -} - -// ── 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) - const [pendingToken, setPendingToken] = useState('') - - 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; totpRequired?: boolean; pendingToken?: string; message?: string } - if (!res.ok) { setAuthError(data.message ?? 'Invalid password.'); setAuthBusy(false); return } - if (data.totpRequired && data.pendingToken) { setPendingToken(data.pendingToken); 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-verify', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pendingToken, code: 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 CalendarClient() { - const navigate = useNavigate() - const today = useMemo(() => new Date(), []) - const [checklist, setChecklist] = useState(null) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [events, setEvents] = useState([]) - - // View - const [viewMode, setViewMode] = useState('month') - const [viewDate, setViewDate] = useState(() => new Date()) - - // Episode scheduling (click-to-place) - const [selectedEpisodeId, setSelectedEpisodeId] = useState(null) - - // Episode edit popover - const [editEp, setEditEp] = useState(null) - const [editForm, setEditForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '', startTime: '', reminderDays: '0', productionStatus: '' as ProductionStatus | '' }) - - // New episode form - const [newEpOpen, setNewEpOpen] = useState(false) - const [newForm, setNewForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '', startTime: '', reminderDays: '0' }) - const [newBusy, setNewBusy] = useState(false) - - // New event form - const [newEvOpen, setNewEvOpen] = useState(false) - const [newEvForm, setNewEvForm] = useState({ type: 'general' as CalendarEventType, title: '', date: '', startTime: '', notes: '', reminderDays: '0', recurrenceFreq: 'none' as RecurrenceFreq, recurrenceUntil: '' }) - const [newEvBusy, setNewEvBusy] = useState(false) - - // Event edit popover - const [editEv, setEditEv] = useState(null) - const [editEvForm, setEditEvForm] = useState({ type: 'general' as CalendarEventType, title: '', date: '', startTime: '', notes: '', reminderDays: '0', recurrenceFreq: 'none' as RecurrenceFreq, recurrenceUntil: '' }) - const [evSaving, setEvSaving] = useState(false) - - // Drag state - const [dragOverKey, setDragOverKey] = useState(null) - const [calFlash, setCalFlash] = useState('') - const dragDataRef = useRef<{ type: 'episode' | 'event'; id: string } | null>(null) - - // ── Load ── - - const load = useCallback(async () => { - try { - const [clRes, evRes] = await Promise.all([ - fetch('/api/admin-podcast-checklist', { credentials: 'include' }), - fetch('/api/admin-calendar-events', { credentials: 'include' }), - ]) - if (clRes.ok) { - const data = await clRes.json() as { checklist: PodcastChecklistData } - setChecklist(data.checklist) - } - if (evRes.ok) { - const data = await evRes.json() as { events: CalendarEvent[] } - setEvents(Array.isArray(data.events) ? data.events : []) - } - } catch { /* silent */ } - setLoading(false) - }, []) - - useEffect(() => { load() }, [load]) - - // ── Navigation ── - - const viewYear = viewDate.getFullYear() - const viewMonth = viewDate.getMonth() - - function prevPeriod() { - if (viewMode === 'month') setViewDate(d => addMonths(d, -1)) - else if (viewMode === 'week') setViewDate(d => addDays(d, -7)) - else setViewDate(d => addDays(d, -1)) - } - - function nextPeriod() { - if (viewMode === 'month') setViewDate(d => addMonths(d, 1)) - else if (viewMode === 'week') setViewDate(d => addDays(d, 7)) - else setViewDate(d => addDays(d, 1)) - } - - function gotoToday() { setViewDate(new Date(today)) } - - // ── Computed days ── - - const calDays = useMemo(() => { - if (viewMode === 'week') { - const monday = getMondayOf(viewDate) - return Array.from({ length: 7 }, (_, i) => ({ date: addDays(monday, i), inMonth: true })) - } - if (viewMode === 'day') { - return [{ date: new Date(viewDate), inMonth: true }] - } - // month - 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 - 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 - }, [viewMode, viewDate, viewYear, viewMonth]) - - const firstKey = calDays.length > 0 ? toDateKey(calDays[0].date) : '' - const lastKey = calDays.length > 0 ? toDateKey(calDays[calDays.length - 1].date) : '' - - // ── Expanded events (with recurring instances) ── - - const expandedEventsByDate = useMemo(() => { - const all = expandRecurring(events, firstKey, lastKey) - const map = new Map() - for (const ev of all) { - const arr = map.get(ev.date) ?? [] - arr.push(ev) - map.set(ev.date, arr) - } - return map - }, [events, firstKey, lastKey]) - - 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) - - // ── View header label ── - - const viewLabel = useMemo(() => { - if (viewMode === 'month') return `${MONTH_NAMES[viewMonth]} ${viewYear}` - if (viewMode === 'week') { - const monday = getMondayOf(viewDate) - const sunday = addDays(monday, 6) - if (monday.getMonth() === sunday.getMonth()) { - return `${MONTH_NAMES[monday.getMonth()]} ${monday.getDate()}–${sunday.getDate()}, ${monday.getFullYear()}` - } - return `${MONTH_NAMES[monday.getMonth()]} ${monday.getDate()} – ${MONTH_NAMES[sunday.getMonth()]} ${sunday.getDate()}, ${monday.getFullYear()}` - } - return `${MONTH_NAMES[viewDate.getMonth()]} ${viewDate.getDate()}, ${viewDate.getFullYear()}` - }, [viewMode, viewDate, viewYear, viewMonth]) - - // ── Checklist save ── - - async function saveChecklist(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) - } - - // ── Episode actions ── - - 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 - ) - saveChecklist(updated) - setSelectedEpisodeId(null) - } - - function announceEpisode(ep: PodcastChecklistEpisode) { - const parts = [ep.series, ep.episodeNumber ? `Episode ${ep.episodeNumber}` : null, ep.title].filter(Boolean) - const subject = `New Episode: ${parts.join(' – ')}` - const dateStr = ep.datePublished - ? new Date(ep.datePublished + 'T12:00:00').toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }) - : '' - const body = [parts.join(' – '), dateStr ? `Published ${dateStr}` : ''].filter(Boolean).join('\n\n') - setEditEp(null) - navigate('/email', { state: { compose: true, subject, body } }) - } - - function openEdit(ep: PodcastChecklistEpisode) { - setEditEp(ep) - setEditForm({ - series: ep.series, - episodeNumber: ep.episodeNumber != null ? String(ep.episodeNumber) : '', - title: ep.title, - datePublished: ep.datePublished?.trim() ?? '', - startTime: ep.startTime ?? '', - reminderDays: String(ep.reminderDays ?? 0), - productionStatus: ep.productionStatus ?? '', - }) - } - - function saveEdit() { - if (!editEp || !checklist) return - const newDate = editForm.datePublished.trim() - const newReminder = Number(editForm.reminderDays) - const dateChanged = newDate !== (editEp.datePublished?.trim() ?? '') - const reminderChanged = newReminder !== (editEp.reminderDays ?? 0) - - let nextStatus = (editForm.productionStatus as ProductionStatus) || undefined - if (dateChanged && newDate) { - const today = new Date().toISOString().slice(0, 10) - const isPast = newDate <= today - if (!nextStatus || nextStatus === 'idea') { - nextStatus = isPast ? 'published' : 'scheduled' - } - } - - 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: newDate, - startTime: editForm.startTime || undefined, - reminderDays: newReminder, - reminderSentAt: dateChanged || reminderChanged ? undefined : ep.reminderSentAt, - productionStatus: nextStatus, - } - : ep - ) - saveChecklist(updated) - setEditEp(null) - } - - async function generateTasks(ep: PodcastChecklistEpisode) { - if (!ep.datePublished) return - const epDate = new Date(ep.datePublished + 'T12:00:00') - const label = ep.title || [ep.series, ep.episodeNumber != null ? `Ep. ${ep.episodeNumber}` : null].filter(Boolean).join(' ') - const tasks = [ - { offsetDays: -14, title: `Record: ${label}` }, - { offsetDays: -7, title: `Edit: ${label}` }, - ] - for (const t of tasks) { - const d = new Date(epDate) - d.setDate(d.getDate() + t.offsetDays) - const dateKey = d.toISOString().slice(0, 10) - try { - const res = await fetch('/api/admin-calendar-events', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ type: 'task', title: t.title, date: dateKey, notes: '', reminderDays: 0 }), - }) - if (res.ok) { - const data = await res.json() as { event: CalendarEvent } - setEvents(prev => [data.event, ...prev]) - } - } catch { /* silent */ } - } - setCalFlash(`Tasks generated for "${label}"`) - setTimeout(() => setCalFlash(''), 3000) - setEditEp(null) - } - - function unschedule(ep: PodcastChecklistEpisode) { - if (!checklist) return - const updated = checklist.episodes.map(e => e.id === ep.id ? { ...e, datePublished: '' } : e) - saveChecklist(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(), - startTime: newForm.startTime || undefined, - reminderDays: Number(newForm.reminderDays), - expanded: false, - tasks: {}, - } - await saveChecklist([...checklist.episodes, ep]) - setNewForm({ series: '', episodeNumber: '', title: '', datePublished: '', startTime: '', reminderDays: '0' }) - setNewEpOpen(false) - setNewBusy(false) - } - - // ── Event actions ── - - async function handleNewEvent(e: React.FormEvent) { - e.preventDefault() - if (!newEvForm.title.trim() || !newEvForm.date) return - setNewEvBusy(true) - try { - const recurrence = newEvForm.recurrenceFreq !== 'none' - ? { freq: newEvForm.recurrenceFreq, until: newEvForm.recurrenceUntil || null } - : null - const res = await fetch('/api/admin-calendar-events', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - type: newEvForm.type, - title: newEvForm.title.trim(), - date: newEvForm.date, - startTime: newEvForm.startTime || undefined, - notes: newEvForm.notes.trim(), - reminderDays: Number(newEvForm.reminderDays), - recurrence, - }), - }) - if (res.ok) { - const data = await res.json() as { event: CalendarEvent } - setEvents(prev => [data.event, ...prev]) - setNewEvForm({ type: 'general', title: '', date: '', startTime: '', notes: '', reminderDays: '0', recurrenceFreq: 'none', recurrenceUntil: '' }) - setNewEvOpen(false) - } - } catch { /* silent */ } - setNewEvBusy(false) - } - - function openEditEvent(ev: CalendarEvent) { - // If clicking a recurring instance, edit the base event - const baseId = ev.recurrenceOf ?? ev.id - const base = events.find(e => e.id === baseId) ?? ev - setEditEv(base) - setEditEvForm({ - type: base.type, - title: base.title, - date: base.date, - startTime: base.startTime ?? '', - notes: base.notes, - reminderDays: String(base.reminderDays), - recurrenceFreq: base.recurrence?.freq ?? 'none', - recurrenceUntil: base.recurrence?.until ?? '', - }) - } - - async function saveEditEvent() { - if (!editEv) return - setEvSaving(true) - try { - const recurrence = editEvForm.recurrenceFreq !== 'none' - ? { freq: editEvForm.recurrenceFreq, until: editEvForm.recurrenceUntil || null } - : null - const res = await fetch(`/api/admin-calendar-events/${editEv.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - type: editEvForm.type, - title: editEvForm.title.trim(), - date: editEvForm.date, - startTime: editEvForm.startTime || null, - notes: editEvForm.notes.trim(), - reminderDays: Number(editEvForm.reminderDays), - recurrence, - }), - }) - if (res.ok) { - setEvents(prev => prev.map(ev => { - if (ev.id !== editEv.id) return ev - const dateChanged = editEvForm.date !== ev.date - const reminderChanged = Number(editEvForm.reminderDays) !== ev.reminderDays - return { - ...ev, - type: editEvForm.type, - title: editEvForm.title.trim(), - date: editEvForm.date, - startTime: editEvForm.startTime || undefined, - notes: editEvForm.notes.trim(), - reminderDays: Number(editEvForm.reminderDays), - recurrence: recurrence ?? undefined, - reminderSentAt: (dateChanged || reminderChanged) ? undefined : ev.reminderSentAt, - } - })) - setEditEv(null) - } - } catch { /* silent */ } - setEvSaving(false) - } - - async function deleteEvent(ev: CalendarEvent) { - const baseId = ev.recurrenceOf ?? ev.id - try { - const res = await fetch(`/api/admin-calendar-events/${baseId}`, { method: 'DELETE', credentials: 'include' }) - if (res.ok) { setEvents(prev => prev.filter(e => e.id !== baseId)); setEditEv(null) } - } catch { /* silent */ } - } - - async function toggleComplete(ev: CalendarEvent) { - const next = !ev.completed - try { - const res = await fetch(`/api/admin-calendar-events/${ev.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ completed: next }), - }) - if (res.ok) setEvents(prev => prev.map(e => e.id === ev.id ? { ...e, completed: next } : e)) - } catch { /* silent */ } - } - - // ── Drag-to-reschedule ── - - function onChipDragStart(e: React.DragEvent, type: 'episode' | 'event', id: string) { - dragDataRef.current = { type, id } - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData('text/plain', JSON.stringify({ type, id })) - } - - async function onDayDrop(e: React.DragEvent, date: Date, inMonth: boolean) { - e.preventDefault() - setDragOverKey(null) - if (!inMonth && viewMode === 'month') return - const dk = toDateKey(date) - let raw: { type: string; id: string } | null = null - try { raw = JSON.parse(e.dataTransfer.getData('text/plain')) } catch {} - const dragged = raw ?? dragDataRef.current - dragDataRef.current = null - if (!dragged) return - - if (dragged.type === 'episode' && checklist) { - const updated = checklist.episodes.map(ep => - ep.id === dragged.id ? { ...ep, datePublished: dk } : ep - ) - saveChecklist(updated) - } else if (dragged.type === 'event') { - // Strip recurrenceOf prefix if this is an instance id (e.g. "baseId:2026-08-01") - const baseId = dragged.id.includes(':') ? dragged.id.split(':')[0] : dragged.id - try { - const res = await fetch(`/api/admin-calendar-events/${baseId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ date: dk }), - }) - if (res.ok) setEvents(prev => prev.map(ev => ev.id === baseId ? { ...ev, date: dk } : ev)) - } catch { /* silent */ } - } - } - - // ── Render chip ── - - function renderEpChip(ep: PodcastChecklistEpisode, _dk: string) { - const statusOpt = PROD_STATUS_OPTIONS.find(o => o.value === ep.productionStatus) - return ( - - ) - } - - function renderEvChip(ev: CalendarEvent) { - const opt = EVENT_TYPE_OPTIONS.find(o => o.value === ev.type) - const isInstance = Boolean(ev.recurrenceOf) - return ( - - ) - } - - // ── Day cell renderer ── - - function renderDayCell({ date, inMonth }: { date: Date; inMonth: boolean }, i: number) { - const dk = toDateKey(date) - const eps = episodesByDate.get(dk) ?? [] - const evs = expandedEventsByDate.get(dk) ?? [] - const isToday = dk === todayKey - const isSelecting = Boolean(selectedEpisodeId) - const isDragOver = dragOverKey === dk - - return ( -
handleDayClick(date, inMonth)} - onDragOver={e => { if (inMonth || viewMode !== 'month') { e.preventDefault(); setDragOverKey(dk) } }} - onDragLeave={() => setDragOverKey(null)} - onDrop={e => onDayDrop(e, date, inMonth)} - > -
- { e.stopPropagation(); setViewDate(date); setViewMode('day') }} - title="Go to day view" - > - {viewMode !== 'month' ? `${MONTH_NAMES[date.getMonth()].slice(0,3)} ${date.getDate()}` : date.getDate()} - -
-
- {eps.map(ep => renderEpChip(ep, dk))} - {evs.map(ev => renderEvChip(ev))} -
-
- ) - } - - // ── Main render ── - - return ( -
- {/* Header */} -
-
- -

{viewLabel}

- - - {saving && Saving…} - {calFlash && {calFlash}} -
-
-
- {(['month', 'week', 'day'] as ViewMode[]).map(mode => ( - - ))} -
-
-
- {selectedEpisodeId && ( - - Click a date to schedule ·{' '} - - - )} - - - - 📅 iCal - - ✉ Email - ← Admin -
-
- - {/* New episode banner */} - {newEpOpen && ( -
-
-

New Episode

-
- - - - - - -
-
- -
-
-
- )} - - {/* New event banner */} - {newEvOpen && ( -
-
-

New Event

-
- - - - - -
-
- - {newEvForm.recurrenceFreq !== 'none' && ( - - )} - -
-
- -
-
-
- )} - - {/* Publishing today banner */} - {!loading && (() => { - const todayEps = (checklist?.episodes ?? []).filter(ep => ep.datePublished === todayKey) - if (todayEps.length === 0) return null - return ( -
- 📣 - - {todayEps.length === 1 - ? `"${todayEps[0].title || `Ep ${todayEps[0].episodeNumber}`}" publishes today` - : `${todayEps.length} episodes publish today`} - - {todayEps.map(ep => ( - - ))} -
- ) - })()} - - {/* Calendar body */} -
-
- {loading ? ( -

Loading calendar…

- ) : ( - <> - {/* Day-of-week headers */} - {viewMode !== 'day' && ( -
- {['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => ( -
{d}
- ))} -
- )} - {/* Grid */} -
- {calDays.map((day, i) => renderDayCell(day, i))} -
- - )} -
- - {/* Sidebar — unscheduled */} - -
- - {/* Episode edit popover */} - {editEp && ( -
setEditEp(null)}> -
e.stopPropagation()}> -
-

Edit Episode

- -
-
- - - - - - - -
-
- - - {editEp.datePublished && } - - -
-
-
- )} - - {/* Event edit popover */} - {editEv && ( -
setEditEv(null)}> -
e.stopPropagation()}> -
-

Edit Event

- -
-
- - - - - - - - {editEvForm.recurrenceFreq !== 'none' && ( - - )} - {editEv.type === 'task' && ( - - )} -
-
- - - -
-
-
- )} -
- ) -} diff --git a/src/ContactsPage.tsx b/src/ContactsPage.tsx deleted file mode 100644 index 0fb03c7..0000000 --- a/src/ContactsPage.tsx +++ /dev/null @@ -1,897 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Link } from 'react-router-dom' - -// ── Types ──────────────────────────────────────────────────────────────────── - -interface EmailDeliveryState { - status: string - lastEventAt: string | null -} - -interface ContactSubmission { - id: string - submittedAt: string - name: string - email: string - message: string - messageType: string - subscribe: boolean - archived?: boolean - starred?: boolean - source?: string - inboundTo?: string - notes?: string - tags?: string[] - emailStatus?: { - welcome: EmailDeliveryState - adminNotification: EmailDeliveryState - adminReply: EmailDeliveryState - } -} - -interface ReplyHistoryItem { - id: string - submissionId: string - toEmail: string - toName: string - fromEmail: string - subject: string - preview: string - sentAt: string - scheduledAt?: string | null -} - -interface Contact { - key: string - email: string - name: string - source: string | undefined - subscribe: boolean - archived: boolean - firstContactAt: string // oldest submission date - latestAt: string // newest submission date - message: string - notes: string - tags: string[] - lastContactedAt: string | null - submissionCount: number - mainId: string - allIds: string[] - allSubmissions: ContactSubmission[] - bestDeliveryStatus: string | null // opened/clicked/delivered/sent/null - unreplied: boolean // has inbound messages, no reply sent - engagementScore: number -} - -type ConversationItem = - | { kind: 'inbound'; date: string; name: string; message: string; source?: string; id: string } - | { kind: 'outbound'; date: string; subject: string; preview: string; toEmail: string } - -interface ChecklistEpisode { - id: string - series: string - episodeNumber: number | null - title: string - datePublished: 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[] { - 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() { - 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) - const [pendingToken, setPendingToken] = useState('') - - useEffect(() => { - fetch('/api/admin-auth/status', { credentials: 'include' }) - .then(r => r.json()) - .then((d: { authenticated?: boolean }) => setAuthState(d.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; totpRequired?: boolean; pendingToken?: string; message?: string } - if (!res.ok) { setAuthError(data.message ?? 'Invalid password.'); setAuthBusy(false); return } - if (data.totpRequired && data.pendingToken) { setPendingToken(data.pendingToken); 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-verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pendingToken, code: 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 [replyHistory, setReplyHistory] = useState([]) - const [checklistEpisodes, setChecklistEpisodes] = useState([]) - const [loading, setLoading] = useState(true) - const [search, setSearch] = useState('') - const [tagFilter, setTagFilter] = useState('') - const [showArchived, setShowArchived] = useState(false) - - // 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('') - // Drip trigger - const [dripBusyKey, setDripBusyKey] = useState(null) - const [dripMsgKey, setDripMsgKey] = useState(null) - const [dripMsgText, setDripMsgText] = 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 [subRes, histRes, clRes] = await Promise.all([ - fetch('/api/admin-contact-submissions', { credentials: 'include' }), - fetch('/api/admin-contact-reply-history', { credentials: 'include' }), - fetch('/api/admin-podcast-checklist', { 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 ?? []) - } - if (clRes.ok) { - const d = await clRes.json() as { checklist?: { episodes?: ChecklistEpisode[] } } - setChecklistEpisodes(d.checklist?.episodes ?? []) - } - } catch { /* silent */ } - setLoading(false) - }, []) - - 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 - const arr = grouped.get(key) ?? [] - 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) - // Best delivery status: clicked > opened > delivered > sent - const STATUS_RANK: Record = { clicked: 4, opened: 3, delivered: 2, sent: 1 } - let bestDeliveryStatus: string | null = null - let bestRank = -1 - for (const s of sorted) { - const st = s.emailStatus?.adminReply?.status - if (st && STATUS_RANK[st] !== undefined && STATUS_RANK[st] > bestRank) { - bestRank = STATUS_RANK[st] - bestDeliveryStatus = st - } - } - - const repliesForContact = replyHistory.filter(r => r.toEmail?.trim().toLowerCase() === emailKey) - const unreplied = sorted.some(s => !s.archived) && repliesForContact.length === 0 - - const engagementScore = - repliesForContact.length * 3 + - sorted.filter(s => s.emailStatus?.adminReply?.status === 'clicked').length * 2 + - sorted.filter(s => s.emailStatus?.adminReply?.status === 'opened').length * 1 + - (sorted.some(s => s.source === 'download') ? 2 : 0) - - return { - key: emailKey, - email: latest.email ?? '', - name: latest.name ?? '', - source: latest.source, - subscribe: sorted.some(s => s.subscribe), - archived: sorted.every(s => s.archived === true), - firstContactAt: oldest.submittedAt, - latestAt: latest.submittedAt, - message: latest.message || sorted.find(s => s.message)?.message || '', - 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, - bestDeliveryStatus, - unreplied, - engagementScore, - } - }) - .sort((a, b) => new Date(b.latestAt).getTime() - new Date(a.latestAt).getTime()) - }, [submissions, replyHistory]) - - // ── 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) { - setEditingKey(c.key) - setEditName(c.name) - setEditNotes(c.notes) - setEditTags([...c.tags]) - setEditTagInput('') - setMergePickerKey(null) - setMergeMsg('') - } - - 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' }) - ) - ) - await reload() - } - - // ── Merge ── - - async function triggerDrip(c: Contact) { - setDripBusyKey(c.key); setDripMsgKey(c.key); setDripMsgText('') - try { - const res = await fetch('/api/admin-contacts/trigger-drip', { - method: 'POST', credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: c.email }), - }) - const d = await res.json() as { ok?: boolean; note?: string; message?: string } - setDripMsgText(res.ok ? (d.note ?? 'Drip triggered.') : (d.message ?? 'Failed.')) - } catch { setDripMsgText('Network error.') } - setDripBusyKey(null) - } - - 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, tags }), - }) - 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 - if (source === 'download') return download - return form - } - - // ── 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

-
- - - -
-