Remove production checklist and email/calendar/contacts app; v1.1.37
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+5
-454
@@ -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<string, boolean>
|
||||
}
|
||||
|
||||
interface PodcastChecklistData {
|
||||
tasks: PodcastChecklistTask[]
|
||||
episodes: PodcastChecklistEpisode[]
|
||||
}
|
||||
|
||||
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'redirects' | 'podcastFeaturedLinks' | 'finishedBooks' | 'testimonials' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
|
||||
|
||||
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<Record<AdminView, AdminSectionLink[]>> = {
|
||||
],
|
||||
}
|
||||
|
||||
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<Array<{ title: string; audioUrl: string; duration: string; episode: string }>>([])
|
||||
|
||||
const [podcastChecklist, setPodcastChecklist] = useState<PodcastChecklistData>({ 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<string, number> }).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<HTMLInputElement>) {
|
||||
async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
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' && <p className="admin-status admin-status--ok">✓ Checklist saved.</p>}
|
||||
{podcastChecklistStatus === 'error' && <p className="admin-status admin-status--err">✗ {podcastChecklistMsg}</p>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
{/* ── Top bar ── */}
|
||||
@@ -3535,8 +3287,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
<button type="button" className={`admin-tab${podcastTab === 'current-series' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('current-series')}>Current Series</button>
|
||||
<button type="button" className={`admin-tab${podcastTab === 'episode-highlights' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-highlights')}>Ep. Highlights</button>
|
||||
<button type="button" className={`admin-tab${podcastTab === 'finished-books' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('finished-books')}>Finished Books</button>
|
||||
<button type="button" className={`admin-tab${podcastTab === 'podcast-checklist' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('podcast-checklist')}>Production Checklist</button>
|
||||
<button type="button" className={`admin-tab${podcastTab === 'episode-scripts' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-scripts')}>Episode Scripts</button>
|
||||
<button type="button" className={`admin-tab${podcastTab === 'episode-scripts' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-scripts')}>Episode Scripts</button>
|
||||
<button type="button" className={`admin-tab${podcastTab === 'rss-feed' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('rss-feed')}>RSS Feed</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -3738,206 +3489,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* PODCAST CHECKLIST */}
|
||||
{(adminView === 'podcast-checklist' || (adminView === 'podcast' && podcastTab === 'podcast-checklist')) && (
|
||||
<section className="admin-panel-section" aria-label="Podcast production checklist">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Podcast Production Checklist</h2>
|
||||
<p>Track production progress for each episode. Add as many tasks and episodes as you need, then save.</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-content-summary">
|
||||
<article className="admin-summary-card">
|
||||
<h3>Total Episodes</h3>
|
||||
<p>{podcastChecklist.episodes.length}</p>
|
||||
</article>
|
||||
<article className="admin-summary-card">
|
||||
<h3>Pre-Publish Tasks</h3>
|
||||
<p>{checklistPreTasks.length}</p>
|
||||
</article>
|
||||
<article className="admin-summary-card">
|
||||
<h3>Post-Publish Tasks</h3>
|
||||
<p>{checklistPostTasks.length}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<AdminCollapsibleCard
|
||||
title="Task Template"
|
||||
subtitle={`${podcastChecklist.tasks.length} tasks configured`}
|
||||
className="admin-collapsible-card--group"
|
||||
>
|
||||
<div className="admin-archive-subsection-actions" style={{ marginBottom: '0.75rem' }}>
|
||||
<button type="button" className="btn-admin-add" onClick={() => addChecklistTask('pre')}>+ Add Pre-Publish Task</button>
|
||||
<button type="button" className="btn-admin-add" onClick={() => addChecklistTask('post')}>+ Add Post-Publish Task</button>
|
||||
</div>
|
||||
|
||||
{podcastChecklist.tasks.length === 0 && (
|
||||
<p className="admin-stats-note">No tasks yet. Add a pre-publish or post-publish task above.</p>
|
||||
)}
|
||||
|
||||
{checklistTasksSorted.map(task => (
|
||||
<div key={task.id} className="admin-array-row admin-array-row--nested">
|
||||
<div className="admin-array-fields" style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: '1fr minmax(11rem, 15rem)' }}>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`checklist-task-label-${task.id}`}>Task Name</label>
|
||||
<input
|
||||
id={`checklist-task-label-${task.id}`}
|
||||
type="text"
|
||||
value={task.label}
|
||||
onChange={e => updateChecklistTask(task.id, 'label', e.target.value)}
|
||||
placeholder="e.g. Upload transcript"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`checklist-task-phase-${task.id}`}>Phase</label>
|
||||
<select
|
||||
id={`checklist-task-phase-${task.id}`}
|
||||
value={task.phase}
|
||||
onChange={e => updateChecklistTask(task.id, 'phase', e.target.value)}
|
||||
>
|
||||
<option value="pre">Pre-Publish</option>
|
||||
<option value="post">Post-Publish</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeChecklistTask(task.id)}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
</AdminCollapsibleCard>
|
||||
|
||||
<div className="admin-archive-subsection">
|
||||
<div className="admin-archive-subsection-head">
|
||||
<h5>Episodes</h5>
|
||||
<div className="admin-archive-subsection-actions">
|
||||
<button type="button" className="btn-admin-add" onClick={addChecklistEpisode}>+ Add Episode</button>
|
||||
<button type="button" className="btn-admin-save" onClick={handleSavePodcastChecklist} disabled={podcastChecklistStatus === 'saving'}>
|
||||
{podcastChecklistStatus === 'saving' ? 'Saving Checklist…' : 'Save Checklist'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{podcastChecklist.episodes.length === 0 && (
|
||||
<p className="admin-stats-note">No episodes yet. Add one above to start tracking progress.</p>
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<AdminCollapsibleCard
|
||||
key={episode.id}
|
||||
title={episodeLabel}
|
||||
subtitle={totalCount > 0
|
||||
? `${doneCount}/${totalCount} tasks completed • Next: ${nextTask?.label ?? 'All done'}`
|
||||
: 'No tasks assigned yet'}
|
||||
>
|
||||
<div className="admin-array-row admin-array-row--nested">
|
||||
<div className="admin-array-fields" style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))' }}>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`checklist-series-${episode.id}`}>Series</label>
|
||||
<input
|
||||
id={`checklist-series-${episode.id}`}
|
||||
type="text"
|
||||
value={episode.series}
|
||||
onChange={e => updateChecklistEpisode(episode.id, 'series', e.target.value)}
|
||||
placeholder="Colossians"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`checklist-episode-number-${episode.id}`}>Episode Number</label>
|
||||
<input
|
||||
id={`checklist-episode-number-${episode.id}`}
|
||||
type="number"
|
||||
value={episode.episodeNumber ?? ''}
|
||||
onChange={e => updateChecklistEpisode(episode.id, 'episodeNumber', e.target.value)}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`checklist-title-${episode.id}`}>Title (optional)</label>
|
||||
<input
|
||||
id={`checklist-title-${episode.id}`}
|
||||
type="text"
|
||||
value={episode.title}
|
||||
onChange={e => updateChecklistEpisode(episode.id, 'title', e.target.value)}
|
||||
placeholder="Grace that Trains Us"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`checklist-date-${episode.id}`}>Date Published</label>
|
||||
<input
|
||||
id={`checklist-date-${episode.id}`}
|
||||
type="date"
|
||||
value={episode.datePublished}
|
||||
onChange={e => updateChecklistEpisode(episode.id, 'datePublished', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{checklistPreTasks.length > 0 && (
|
||||
<div className="admin-archive-subsection">
|
||||
<div className="admin-archive-subsection-head">
|
||||
<h5>Pre-Publish Tasks</h5>
|
||||
</div>
|
||||
<div className="admin-array-fields">
|
||||
{checklistPreTasks.map(task => (
|
||||
<label key={task.id} style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={episode.tasks[task.id] === true}
|
||||
onChange={() => toggleChecklistEpisodeTask(episode.id, task.id)}
|
||||
/>
|
||||
<span>{task.label || 'Untitled task'}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checklistPostTasks.length > 0 && (
|
||||
<div className="admin-archive-subsection">
|
||||
<div className="admin-archive-subsection-head">
|
||||
<h5>Post-Publish Tasks</h5>
|
||||
</div>
|
||||
<div className="admin-array-fields">
|
||||
{checklistPostTasks.map(task => (
|
||||
<label key={task.id} style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={episode.tasks[task.id] === true}
|
||||
onChange={() => toggleChecklistEpisodeTask(episode.id, task.id)}
|
||||
/>
|
||||
<span>{task.label || 'Untitled task'}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.6rem', marginTop: '0.8rem' }}>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => resetChecklistEpisode(episode.id)}>Reset Progress</button>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeChecklistEpisode(episode.id)}>Remove Episode</button>
|
||||
</div>
|
||||
</AdminCollapsibleCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{renderPodcastChecklistStatus()}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* DOWNLOADS */}
|
||||
{adminView === 'downloads' && (
|
||||
<section className="admin-panel-section">
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
|
||||
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
|
||||
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
|
||||
<Route path="/email" element={<EmailShell />} />
|
||||
<Route path="/contacts" element={<ContactsShell />} />
|
||||
<Route path="/calendar" element={<CalendarShell />} />
|
||||
<Route path="/preview" element={<PreviewPage />} />
|
||||
<Route
|
||||
path="/privacy"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<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() {
|
||||
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 <div className="em-auth-loading">Loading…</div>
|
||||
|
||||
if (authState === 'needs-password') return (
|
||||
<div className="em-auth-wrap">
|
||||
<form className="em-auth-form" onSubmit={handleLogin}>
|
||||
<h1 className="em-auth-title">Contacts</h1>
|
||||
<label className="em-auth-label">Admin password<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>}
|
||||
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>{authBusy ? 'Signing in…' : 'Sign in'}</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (authState === 'needs-totp') return (
|
||||
<div className="em-auth-wrap">
|
||||
<form className="em-auth-form" onSubmit={handleTotp}>
|
||||
<h1 className="em-auth-title">Two-factor code</h1>
|
||||
<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>
|
||||
{authError && <p className="em-auth-error">{authError}</p>}
|
||||
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>{authBusy ? 'Verifying…' : 'Verify'}</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
||||
return <ContactsClient />
|
||||
}
|
||||
|
||||
// ── Contacts Client ───────────────────────────────────────────────────────────
|
||||
|
||||
function ContactsClient() {
|
||||
const [submissions, setSubmissions] = useState<ContactSubmission[]>([])
|
||||
const [replyHistory, setReplyHistory] = useState<ReplyHistoryItem[]>([])
|
||||
const [checklistEpisodes, setChecklistEpisodes] = useState<ChecklistEpisode[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [tagFilter, setTagFilter] = useState('')
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
|
||||
// Edit state
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editNotes, setEditNotes] = useState('')
|
||||
const [editTags, setEditTags] = useState<string[]>([])
|
||||
const [editTagInput, setEditTagInput] = useState('')
|
||||
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('')
|
||||
// Drip trigger
|
||||
const [dripBusyKey, setDripBusyKey] = useState<string | null>(null)
|
||||
const [dripMsgKey, setDripMsgKey] = useState<string | null>(null)
|
||||
const [dripMsgText, setDripMsgText] = useState('')
|
||||
|
||||
// History state
|
||||
const [expandedHistoryKey, setExpandedHistoryKey] = useState<string | null>(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<string, string>[]; filename: string } | null>(null)
|
||||
const importFileRef = useRef<HTMLInputElement>(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<string, ContactSubmission[]>()
|
||||
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<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())
|
||||
.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<string, number> = { 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<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 (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<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) {
|
||||
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 === 'download') return <span className="ct-badge ct-badge--download">download</span>
|
||||
return <span className="ct-badge ct-badge--form">form</span>
|
||||
}
|
||||
|
||||
// ── Render ──
|
||||
|
||||
return (
|
||||
<div className="ct-app">
|
||||
{/* Header */}
|
||||
<header className="ct-header">
|
||||
<div className="ct-header-brand">
|
||||
<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" /><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" />
|
||||
</svg>
|
||||
Contacts
|
||||
<span className="ct-header-count">{contacts.length}</span>
|
||||
</div>
|
||||
<div className="ct-header-actions">
|
||||
<button type="button" className="em-btn em-btn--secondary em-btn--sm" onClick={() => { setAddOpen(o => !o); setAddError('') }}>
|
||||
{addOpen ? 'Cancel' : '+ Add Contact'}
|
||||
</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="/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>
|
||||
</div>
|
||||
</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 && (
|
||||
<div className="ct-add-banner">
|
||||
<form className="ct-add-form" onSubmit={handleAdd}>
|
||||
<h3 className="ct-add-title">Add Contact</h3>
|
||||
<div className="ct-add-row">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
{addError && <p className="ct-error">{addError}</p>}
|
||||
<div className="ct-add-actions">
|
||||
<button type="submit" className="em-btn em-btn--primary em-btn--sm" disabled={addBusy}>{addBusy ? 'Adding…' : 'Add Contact'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="ct-toolbar">
|
||||
<input type="search" className="ct-search" placeholder="Search name, email, notes, tags…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
<select className="ct-tag-filter" value={tagFilter} onChange={e => setTagFilter(e.target.value)}>
|
||||
<option value="">All tags</option>
|
||||
{allTags.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<label className="ct-archived-toggle">
|
||||
<input type="checkbox" checked={showArchived} onChange={e => setShowArchived(e.target.checked)} />
|
||||
Show archived
|
||||
</label>
|
||||
<span className="ct-count-label">{filtered.length} contact{filtered.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
|
||||
{/* Contact list */}
|
||||
<div className="ct-list">
|
||||
{loading && <p className="ct-empty">Loading contacts…</p>}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<p className="ct-empty">{search || tagFilter ? 'No contacts match.' : 'No contacts yet.'}</p>
|
||||
)}
|
||||
|
||||
{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 className="ct-avatar" aria-hidden="true">
|
||||
{(c.name || c.email || '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
|
||||
<div className="ct-card-body">
|
||||
{isEditing ? (
|
||||
/* ── Edit mode ── */
|
||||
<div className="ct-edit-form">
|
||||
<div className="ct-edit-row">
|
||||
<label className="ct-edit-label">Name<input className="ct-input" type="text" value={editName} onChange={e => setEditName(e.target.value)} autoFocus /></label>
|
||||
</div>
|
||||
<label className="ct-edit-label">
|
||||
Tags
|
||||
<div className="ct-tag-editor">
|
||||
{editTags.map(t => (
|
||||
<span key={t} className="ct-tag" style={{ background: tagBg(t) }}>
|
||||
{t}
|
||||
<button type="button" className="ct-tag-remove" onClick={() => removeEditTag(t)} aria-label={`Remove ${t}`}>×</button>
|
||||
</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 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">
|
||||
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={() => saveEdit(c)} disabled={editSaving}>{editSaving ? 'Saving…' : 'Save'}</button>
|
||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setMergePickerKey(prev => prev === c.key ? null : c.key); setMergeSearch(''); setMergeMsg('') }}>
|
||||
{isMergeTarget ? 'Cancel merge' : 'Merge with…'}
|
||||
</button>
|
||||
{c.email && (
|
||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" title="Sync to Resend audience and trigger drip automation" onClick={() => triggerDrip(c)} disabled={dripBusyKey === c.key}>
|
||||
{dripBusyKey === c.key ? 'Triggering…' : '▶ Drip'}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={cancelEdit}>Cancel</button>
|
||||
</div>
|
||||
{dripMsgKey === c.key && dripMsgText && (
|
||||
<p className="ct-drip-msg">{dripMsgText}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* ── Display mode ── */
|
||||
<>
|
||||
<div className="ct-card-top">
|
||||
<span className="ct-card-name">{c.name || <em>No name</em>}</span>
|
||||
{c.email && <a className="ct-card-email" href={`mailto:${c.email}`}>{c.email}</a>}
|
||||
{sourceBadge(c.source)}
|
||||
{c.subscribe && <span className="ct-badge ct-badge--sub">subscriber</span>}
|
||||
{c.unreplied && <span className="ct-badge ct-badge--unreplied" title="No reply sent yet">needs reply</span>}
|
||||
{c.bestDeliveryStatus && (
|
||||
<span className={`ct-delivery-pill ct-delivery-pill--${c.bestDeliveryStatus}`}>
|
||||
{c.bestDeliveryStatus === 'clicked' ? '🔗 clicked' : c.bestDeliveryStatus === 'opened' ? '👁 opened' : c.bestDeliveryStatus === 'delivered' ? '✓ delivered' : '→ sent'}
|
||||
</span>
|
||||
)}
|
||||
{c.engagementScore >= 3 && <span className="ct-engage-badge" title={`Engagement score: ${c.engagementScore}`}>{'★'.repeat(Math.min(3, Math.floor(c.engagementScore / 3)))}</span>}
|
||||
{c.submissionCount > 1 && <span className="ct-count-badge">{c.submissionCount}</span>}
|
||||
</div>
|
||||
|
||||
{/* Tags row */}
|
||||
{c.tags.length > 0 && (
|
||||
<div className="ct-tags-row">
|
||||
{c.tags.map(t => (
|
||||
<span key={t} className="ct-tag" style={{ background: tagBg(t) }}>{t}</span>
|
||||
))}
|
||||
</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>
|
||||
</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>
|
||||
|
||||
{/* Actions */}
|
||||
{!isEditing && (
|
||||
<div className="ct-card-actions">
|
||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => startEdit(c)}>Edit</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`em-btn em-btn--sm ${historyOpen ? 'em-btn--secondary' : 'em-btn--ghost'}`}
|
||||
onClick={() => setExpandedHistoryKey(prev => prev === c.key ? null : c.key)}
|
||||
>
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conversation history panel */}
|
||||
{historyOpen && !isEditing && (() => {
|
||||
const relatedEps = checklistEpisodes.filter(ep => {
|
||||
if (!ep.datePublished) return false
|
||||
const epMs = new Date(ep.datePublished + 'T12:00:00').getTime()
|
||||
const refMs = new Date(c.latestAt).getTime()
|
||||
return Math.abs(epMs - refMs) <= 30 * 24 * 60 * 60 * 1000
|
||||
})
|
||||
return (
|
||||
<div className="ct-history-panel">
|
||||
{history.length === 0 && relatedEps.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>
|
||||
)
|
||||
))}
|
||||
{relatedEps.length > 0 && (
|
||||
<div className="ct-history-ep-section">
|
||||
<p className="ct-history-ep-label">📅 Episodes near this contact</p>
|
||||
{relatedEps.map(ep => {
|
||||
const parts = [ep.series, ep.episodeNumber != null ? `Ep. ${ep.episodeNumber}` : null, ep.title].filter(Boolean)
|
||||
return (
|
||||
<div key={ep.id} className="ct-history-item ct-history-item--ep">
|
||||
<div className="ct-history-meta">
|
||||
<span className="ct-history-who">{parts.join(' – ') || 'Untitled episode'}</span>
|
||||
<span className="ct-history-date">{ep.datePublished}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-1557
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user