15 Commits

Author SHA1 Message Date
nmemmert 53405dad9b Fix CI: use email address as Gitea registry username
Publish Container / docker (push) Waiting to run
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 12:59:18 -04:00
nmemmert 2d1c28b467 Fix CI: use Gitea username 'nate' for container registry login
Publish Container / docker (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 12:44:59 -04:00
nmemmert a4a1fdd891 Fix CI: hardcode Gitea username for container registry login
Publish Container / docker (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 12:26:59 -04:00
nmemmert 6d4bcb2ae2 Fix CI: use github.actor context for Gitea registry login
Publish Container / docker (push) Failing after 11m20s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 12:24:36 -04:00
nmemmert 293d9bee4e Switch CI to Gitea container registry; drop GHCR
Publish Container / docker (push) Failing after 10m54s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 12:11:09 -04:00
nmemmert 0512d40fc6 Redesign resources page with PageBanner, featured download, and consistent page headers; v1.1.40
Publish Container / docker (push) Has been cancelled
- Replace hero study guide form with PageBanner + featured download card in library
- Add PageBanner component used consistently across all interior pages (Downloads, Episodes, About, Contact, Q&A, Finished Books)
- Remove duplicate headings/eyebrows that conflicted with banner titles on About, Contact, Q&A, Finished Books pages
- Compact download card sizing (padding, border-radius, image size)
- Add Books & Resources tabbed section with fallback to TruthForLife widget
- Fix server sanitizer to pass through 'resources' placement items without URL
- Add featured-badge and featured card styling for primary guide

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 11:03:43 -04:00
nmemmert 0fdf7f0f50 v1.1.39
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 08:49:32 -04:00
nmemmert 005da43738 Default book button label to 'Learn More' when using url instead of amazonUrl; v1.1.38
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 08:47:25 -04:00
nmemmert 24d7fd7cec Fix book card button to render when url is set but amazonUrl is not; v1.1.38
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 08:42:09 -04:00
nmemmert 889baf23ca Fix server sanitizer to allow books/recommended placements and linkUrl/linkLabel fields; v1.1.38
- Add 'books' and 'recommended' to the placement whitelist in sanitizeCustomLinks
- Allow items with no url when placement is books/recommended (they use amazonUrl)
- Preserve linkUrl and linkLabel through sanitizeCustomBlocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 08:23:59 -04:00
nmemmert eb1d3ddd9c Add Books + editable Recommended Resources sections; improve content block links; v1.1.38
- CustomBlock: add linkUrl/linkLabel fields for a styled button below the body
- Body text auto-linkifies raw URLs into gold anchor tags
- New 'books' placement: Reading List grid on Downloads page (cover, desc, Amazon btn)
- New 'recommended' placement: editable Recommended Resources section on Downloads
  page; falls back to Truth For Life widget when empty
- Admin: Books and Recommended Resources panels added to Content nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 08:16:36 -04:00
nmemmert a35e04320a Remove production checklist and email/calendar/contacts app; v1.1.37
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 09:30:19 -04:00
nmemmert 2ba9c9f7f4 Add Fetch from BSB button to admin lesson editor; v1.1.36
Adds a one-click "Fetch from BSB" button in the lesson passage text field
that calls the BSB API and overwrites the field with verse-numbered text.
Also fixes reference parsing to tolerate leading book abbreviations like
t:1-2 or col:1-2 by stripping letters before the chapter:verse pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-10 10:23:48 -04:00
nmemmert 9e602e29ec Fetch BSB passage text from Bolls.life API in study lesson view; v1.1.35
Adds /api/bible-passage server route that proxies the Berean Standard Bible
from Bolls.life per-verse, with in-memory caching. Study lesson scripture
block now displays live BSB text instead of static passageText. Adds
bibleBook field to StudyProgram so admins can set the book name explicitly
when the study slug doesn't match (e.g. a study titled "ephesians-part-2").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-10 10:12:25 -04:00
nmemmert 6acc3f1d04 Analytics: 3-tab layout + fix geographic data loss; v1.1.34
Splits the analytics panel into Traffic / Engagement / System tabs so
episode plays, engagement metrics, and contact/study data are no longer
buried under the visitor log.

Fixes a bug where topCountries, topStates, topCities, device breakdown,
and referrers were computed from only the 100 most-recent visitor rows
instead of all rows in the selected range — causing older geographic data
(e.g. visits from India, Texas, Georgia) to silently disappear from the
aggregate totals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-10 09:22:47 -04:00
18 changed files with 780 additions and 4135 deletions
+5 -5
View File
@@ -36,18 +36,18 @@ jobs:
- name: Run production build
run: npm run build
- name: Log in to GHCR
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
registry: git.necloud.us
username: nate@versebyversewithnate.us
password: ${{ secrets.GITEA_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/nmemmert/siteforge
images: git.necloud.us/nate/siteforge
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha
+3 -2
View File
@@ -9,8 +9,8 @@
},
"timeOnPage": {
"/": {
"totalSeconds": 9,
"count": 2
"totalSeconds": 51,
"count": 4
},
"/episodes": {
"totalSeconds": 318967,
@@ -22,6 +22,7 @@
}
},
"outboundClicks": {},
"linkClicks": {},
"utmSources": {},
"searchQueries": {},
"notFound": {},
+1 -1
View File
@@ -1,4 +1,4 @@
{
"users": {},
"updatedAt": "2026-08-10T12:37:17.805Z"
"updatedAt": "2026-08-10T14:22:31.192Z"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "1.1.33",
"version": "1.1.40",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -63,6 +63,7 @@ import { register as registerDownloads } from './server/routes/downloads.js'
import { register as registerEpisodes } from './server/routes/episodes.js'
import { register as registerFeed } from './server/routes/feed.js'
import { register as registerQrCodes } from './server/routes/qr-codes.js'
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
import { register as registerPublic } from './server/routes/public.js'
const app = express()
@@ -110,6 +111,7 @@ registerDownloads(app)
registerEpisodes(app)
registerFeed(app)
registerQrCodes(app)
registerBiblePassage(app)
// Hit-counting middleware (must come before public routes)
app.use((req, res, next) => {
+5 -1
View File
@@ -167,6 +167,8 @@ function sanitizeCustomLinks(value) {
|| item?.placement === 'resources'
|| item?.placement === 'otherSites'
|| item?.placement === 'externalSites'
|| item?.placement === 'books'
|| item?.placement === 'recommended'
? item.placement
: 'footer'
@@ -184,7 +186,7 @@ function sanitizeCustomLinks(value) {
placement,
}
})
.filter(item => item.label && item.url)
.filter(item => item.label && (item.url || item.amazonUrl || item.placement === 'books' || item.placement === 'recommended' || item.placement === 'resources'))
}
function sanitizeCustomBlocks(value) {
@@ -198,6 +200,8 @@ function sanitizeCustomBlocks(value) {
page: item?.page === 'homepage' || item?.page === 'start-here' || item?.page === 'episodes' || item?.page === 'downloads' || item?.page === 'about' || item?.page === 'contact' || item?.page === 'questions'
? item.page
: 'downloads',
linkUrl: sanitizeUrl(item.linkUrl),
linkLabel: typeof item.linkLabel === 'string' ? item.linkLabel.trim().slice(0, 120) : '',
}))
.filter(item => item.heading || item.body)
}
+10 -6
View File
@@ -253,6 +253,10 @@ export function register(app) {
const botReasons = Object.entries(state.hitStats.botReasons ?? {})
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([reason, count]) => ({ reason, count }))
// All range rows used for geographic/device/referrer aggregates — no cap so no visits are invisible
const allRangeRows = rangeVisitorRows
// Capped to 100 for the visitor log display (with page history hydrated)
const recentVisitorRows = rangeVisitorRows.slice(0, 100).map(row => {
const fullVisitor = state.visitorStats.visitors[row.visitorId]
return { ...row, pageHistory: fullVisitor?.pageHistory ?? [] }
@@ -315,13 +319,13 @@ export function register(app) {
returningVisits: state.visitorStats.returningVisits,
firstVisitAt: state.visitorStats.firstVisitAt,
lastVisitAt: state.visitorStats.lastVisitAt,
topCountries: buildTopLocations(recentVisitorRows, 'country'),
topStates: buildTopLocations(recentVisitorRows, 'state'),
topCounties: buildTopLocations(recentVisitorRows, 'county'),
topCities: buildTopLocations(recentVisitorRows, 'city'),
topCountries: buildTopLocations(allRangeRows, 'country'),
topStates: buildTopLocations(allRangeRows, 'state'),
topCounties: buildTopLocations(allRangeRows, 'county'),
topCities: buildTopLocations(allRangeRows, 'city'),
deviceBreakdown: (() => {
const counts = { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
for (const row of recentVisitorRows) {
for (const row of allRangeRows) {
const d = row.device ?? 'unknown'
counts[d] = (counts[d] ?? 0) + 1
}
@@ -329,7 +333,7 @@ export function register(app) {
})(),
topReferrers: (() => {
const counts = {}
for (const row of recentVisitorRows) {
for (const row of allRangeRows) {
if (!row.referrer) continue
counts[row.referrer] = (counts[row.referrer] ?? 0) + 1
}
+77
View File
@@ -0,0 +1,77 @@
const passageCache = new Map()
const BOLLS_BOOK_MAP = {
genesis: 1, exodus: 2, leviticus: 3, numbers: 4, deuteronomy: 5,
joshua: 6, judges: 7, ruth: 8, '1samuel': 9, '2samuel': 10,
'1kings': 11, '2kings': 12, '1chronicles': 13, '2chronicles': 14,
ezra: 15, nehemiah: 16, esther: 17, job: 18, psalms: 19, proverbs: 20,
ecclesiastes: 21, songofsolomon: 22, isaiah: 23, jeremiah: 24,
lamentations: 25, ezekiel: 26, daniel: 27, hosea: 28, joel: 29,
amos: 30, obadiah: 31, jonah: 32, micah: 33, nahum: 34, habakkuk: 35,
zephaniah: 36, haggai: 37, zechariah: 38, malachi: 39,
matthew: 40, mark: 41, luke: 42, john: 43, acts: 44,
romans: 45, '1corinthians': 46, '2corinthians': 47, galatians: 48,
ephesians: 49, philippians: 50, colossians: 51, '1thessalonians': 52,
'2thessalonians': 53, '1timothy': 54, '2timothy': 55, titus: 56,
philemon: 57, hebrews: 58, james: 59, '1peter': 60, '2peter': 61,
'1john': 62, '2john': 63, '3john': 64, jude: 65, revelation: 66,
}
function parseReference(reference) {
const stripped = reference.replace(/^[a-z]+/i, '')
const match = stripped.match(/^(\d+):(\d+)(?:-(\d+))?$/)
if (!match) return null
return {
chapter: parseInt(match[1], 10),
verseStart: parseInt(match[2], 10),
verseEnd: match[3] ? parseInt(match[3], 10) : parseInt(match[2], 10),
}
}
export function register(app) {
app.get('/api/bible-passage', async (req, res) => {
const book = String(req.query.book ?? '').trim().toLowerCase().replace(/[\s-]/g, '')
const reference = String(req.query.reference ?? '').trim()
const bookNum = BOLLS_BOOK_MAP[book]
if (!bookNum) {
res.status(400).json({ message: 'Unknown book.' })
return
}
const parsed = parseReference(reference)
if (!parsed) {
res.status(400).json({ message: 'Invalid reference format. Expected format: chapter:verseStart-verseEnd' })
return
}
const cacheKey = `BSB:${bookNum}:${parsed.chapter}:${parsed.verseStart}:${parsed.verseEnd}`
if (passageCache.has(cacheKey)) {
res.json({ text: passageCache.get(cacheKey) })
return
}
try {
const verseNums = []
for (let v = parsed.verseStart; v <= parsed.verseEnd; v++) verseNums.push(v)
const results = await Promise.all(
verseNums.map(v =>
fetch(`https://bolls.life/get-verse/BSB/${bookNum}/${parsed.chapter}/${v}/`, { signal: AbortSignal.timeout(8000) })
.then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)))
)
)
if (results.length === 0) {
res.status(404).json({ message: 'Passage not found.' })
return
}
const text = results.map(v => `v. ${v.verse} ${String(v.text).trim()}`).join('\n\n')
passageCache.set(cacheKey, text)
res.json({ text })
} catch {
res.status(502).json({ message: 'Unable to fetch passage right now.' })
}
})
}
+158 -454
View File
@@ -21,6 +21,28 @@ interface SortableLessonSectionProps {
function SortableLessonSection({ section, study, updateStudySection, removeStudySection, rssEpisodes }: SortableLessonSectionProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id })
const [bsbFetching, setBsbFetching] = useState(false)
const [bsbMsg, setBsbMsg] = useState('')
async function fetchBsbPassage() {
const bookKey = (study.bibleBook?.trim() || study.slug).toLowerCase()
const reference = section.reference?.trim()
if (!bookKey || !reference) { setBsbMsg('Set the passage reference first.'); return }
setBsbFetching(true)
setBsbMsg('')
try {
const params = new URLSearchParams({ book: bookKey, reference })
const res = await fetch(`/api/bible-passage?${params}`)
const data = await res.json() as { text?: string; message?: string }
if (!res.ok || !data.text) { setBsbMsg(data.message ?? 'Passage not found.'); return }
updateStudySection(study.id, section.id, 'passageText', data.text)
setBsbMsg('Passage filled from BSB.')
} catch {
setBsbMsg('Could not reach the BSB API.')
} finally {
setBsbFetching(false)
}
}
const style = {
transform: CSS.Transform.toString(transform),
transition,
@@ -94,8 +116,14 @@ function SortableLessonSection({ section, study, updateStudySection, removeStudy
<textarea id={`study-section-announcement-${study.id}-${section.id}`} rows={3} value={section.announcement ?? ''} placeholder="A short instructor announcement for this lesson." onChange={e => updateStudySection(study.id, section.id, 'announcement', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-passage-${study.id}-${section.id}`}>Passage Text</label>
<textarea id={`study-section-passage-${study.id}-${section.id}`} rows={4} value={section.passageText} placeholder="Paste the passage text here." onChange={e => updateStudySection(study.id, section.id, 'passageText', e.target.value)} />
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '0.35rem' }}>
<label htmlFor={`study-section-passage-${study.id}-${section.id}`} style={{ margin: 0 }}>Passage Text</label>
<button type="button" className="btn-admin-secondary" disabled={bsbFetching} onClick={fetchBsbPassage} style={{ fontSize: '0.8rem', padding: '0.2rem 0.75rem' }}>
{bsbFetching ? 'Fetching…' : 'Fetch from BSB'}
</button>
{bsbMsg && <span style={{ fontSize: '0.8rem', color: bsbMsg.includes('filled') ? '#7abf7a' : '#c97070' }}>{bsbMsg}</span>}
</div>
<textarea id={`study-section-passage-${study.id}-${section.id}`} rows={4} value={section.passageText} placeholder="Paste the passage text here, or use Fetch from BSB." onChange={e => updateStudySection(study.id, section.id, 'passageText', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-commentary-${study.id}-${section.id}`}>Commentary</label>
@@ -665,35 +693,13 @@ 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'
| 'downloads' | 'custom-links' | 'content-blocks'
| 'podcast' | 'current-series' | 'episode-highlights'
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'subscribers' | 'study-users' | 'email-templates'
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
@@ -725,6 +731,8 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
{ value: 'downloads', label: 'Downloads' },
{ value: 'custom-links', label: 'Custom Links' },
{ value: 'content-blocks', label: 'Content Blocks' },
{ value: 'books', label: 'Books' },
{ value: 'recommended-resources', label: 'Recommended Resources' },
],
},
{
@@ -809,7 +817,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'
@@ -1175,9 +1183,6 @@ 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 [manualQuestion, setManualQuestion] = useState({
firstName: '',
@@ -1395,16 +1400,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(() => {
@@ -1713,149 +1708,6 @@ 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>) {
const file = event.target.files?.[0]
if (!file) return
@@ -2146,11 +1998,15 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
if (placement === 'footer') return 'Footer navigation links'
if (placement === 'otherSites') return 'Other site links in footer dropdown'
if (placement === 'externalSites') return 'Homepage external site links'
if (placement === 'books') return 'Downloads page (Reading List)'
if (placement === 'recommended') return 'Downloads page (Recommended Resources)'
return 'Downloads page (More Resources)'
}
function getCustomLinkPreviewPath(placement: CustomLink['placement']) {
if (placement === 'resources') return '/resources'
if (placement === 'books') return '/resources'
if (placement === 'recommended') return '/resources'
return '/'
}
@@ -2312,6 +2168,26 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}))
}
function addBook() {
setForm(f => ({
...f,
customLinks: [
...(f.customLinks ?? []),
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', description: '', amazonUrl: '', amazonLabel: 'View on Amazon', placement: 'books' as const },
],
}))
}
function addRecommended() {
setForm(f => ({
...f,
customLinks: [
...(f.customLinks ?? []),
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', description: '', placement: 'recommended' as const },
],
}))
}
function updateLink(id: string, field: keyof CustomLink, value: string | string[]) {
setForm(f => ({
...f,
@@ -2745,67 +2621,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 (
<>
@@ -2815,15 +2630,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 ── */}
@@ -3507,7 +3313,6 @@ 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 === 'rss-feed' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('rss-feed')}>RSS Feed</button>
</div>
@@ -3710,206 +3515,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">
@@ -4249,6 +3854,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<label htmlFor={`block-body-${block.id}`}>Body Text</label>
<textarea id={`block-body-${block.id}`} value={block.body} rows={3} placeholder="Write your content here…" onChange={e => updateBlock(block.id, 'body', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`block-link-url-${block.id}`}>Button Link URL <span className="admin-field-optional">(optional)</span></label>
<input id={`block-link-url-${block.id}`} type="url" value={block.linkUrl ?? ''} placeholder="https://…" onChange={e => updateBlock(block.id, 'linkUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`block-link-label-${block.id}`}>Button Label <span className="admin-field-optional">(optional)</span></label>
<input id={`block-link-label-${block.id}`} type="text" value={block.linkLabel ?? ''} placeholder="e.g. Get the book on Amazon" onChange={e => updateBlock(block.id, 'linkLabel', e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeBlock(block.id)}>Remove</button>
</div>
@@ -4258,6 +3871,92 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</section>
)}
{/* BOOKS */}
{adminView === 'books' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Books</h2>
<p>Amazon books shown in the Reading List section on the Downloads page. Add a cover image, description, and Amazon link for each book.</p>
</div>
{(form.customLinks ?? []).filter(l => l.placement === 'books').length === 0 && <p className="admin-stats-note">No books yet.</p>}
{(form.customLinks ?? []).filter(l => l.placement === 'books').map(book => (
<div key={book.id} className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`book-title-${book.id}`}>Title</label>
<input id={`book-title-${book.id}`} type="text" value={book.label} placeholder="Book title" onChange={e => updateLink(book.id, 'label', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`book-description-${book.id}`}>Description</label>
<textarea id={`book-description-${book.id}`} rows={3} value={book.description ?? ''} placeholder="Short description of the book" onChange={e => updateLink(book.id, 'description', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`book-image-${book.id}`}>Cover Image URL</label>
<input id={`book-image-${book.id}`} type="text" value={book.imageUrl ?? ''} placeholder="/uploads/cover.jpg" onChange={e => updateLink(book.id, 'imageUrl', e.target.value)} />
{renderImageAssetSelector(book.imageUrl, value => updateLink(book.id, 'imageUrl', value), `book-image-${book.id}-asset`)}
{renderImagePreview(book.imageUrl, `${book.label || 'Book'} cover`)}
</div>
<div className="admin-field">
<label htmlFor={`book-amazon-url-${book.id}`}>Amazon URL</label>
<input id={`book-amazon-url-${book.id}`} type="url" value={book.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateLink(book.id, 'amazonUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`book-amazon-label-${book.id}`}>Amazon Button Label</label>
<input id={`book-amazon-label-${book.id}`} type="text" value={book.amazonLabel ?? ''} placeholder="View on Amazon" onChange={e => updateLink(book.id, 'amazonLabel', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`book-url-${book.id}`}>Link URL <span className="admin-field-optional">(used if no Amazon URL is set)</span></label>
<input id={`book-url-${book.id}`} type="url" value={book.url ?? ''} placeholder="https://..." onChange={e => updateLink(book.id, 'url', e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeLink(book.id)}>Remove</button>
</div>
))}
<button type="button" className="btn-admin-add" onClick={addBook}>+ Add Book</button>
{renderSaveStatus()}
</section>
)}
{/* RECOMMENDED RESOURCES */}
{adminView === 'recommended-resources' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Recommended Resources</h2>
<p>Curated resources shown at the bottom of the Downloads page. When at least one is added, it replaces the default Truth For Life widget. Add a title, image, link, and description for each resource.</p>
</div>
{(form.customLinks ?? []).filter(l => l.placement === 'recommended').length === 0 && (
<p className="admin-stats-note">No custom recommended resources yet the Truth For Life widget shows by default.</p>
)}
{(form.customLinks ?? []).filter(l => l.placement === 'recommended').map(item => (
<div key={item.id} className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`rec-title-${item.id}`}>Title</label>
<input id={`rec-title-${item.id}`} type="text" value={item.label} placeholder="Resource title" onChange={e => updateLink(item.id, 'label', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`rec-url-${item.id}`}>Link URL</label>
<input id={`rec-url-${item.id}`} type="url" value={item.url} placeholder="https://..." onChange={e => updateLink(item.id, 'url', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`rec-description-${item.id}`}>Description</label>
<textarea id={`rec-description-${item.id}`} rows={3} value={item.description ?? ''} placeholder="What is this resource?" onChange={e => updateLink(item.id, 'description', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`rec-image-${item.id}`}>Image URL</label>
<input id={`rec-image-${item.id}`} type="text" value={item.imageUrl ?? ''} placeholder="/uploads/example.jpg" onChange={e => updateLink(item.id, 'imageUrl', e.target.value)} />
{renderImageAssetSelector(item.imageUrl, value => updateLink(item.id, 'imageUrl', value), `rec-image-${item.id}-asset`)}
{renderImagePreview(item.imageUrl, `${item.label || 'Resource'} image`)}
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeLink(item.id)}>Remove</button>
</div>
))}
<button type="button" className="btn-admin-add" onClick={addRecommended}>+ Add Recommended Resource</button>
{renderSaveStatus()}
</section>
)}
{/* STUDIES */}
{adminView === 'colossians-study' && (
<section className="admin-panel-section">
@@ -4285,6 +3984,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<label htmlFor={`study-slug-${study.id}`}>Slug</label>
<input id={`study-slug-${study.id}`} type="text" value={study.slug} placeholder="colossians" onChange={e => updateStudyProgram(study.id, 'slug', e.target.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'))} />
</div>
<div className="admin-field">
<label htmlFor={`study-bible-book-${study.id}`}>Bible Book (for passage lookup)</label>
<input id={`study-bible-book-${study.id}`} type="text" value={study.bibleBook ?? ''} placeholder="colossians (leave blank to use slug)" onChange={e => updateStudyProgram(study.id, 'bibleBook', e.target.value.trim().toLowerCase().replace(/\s+/g, ''))} />
<p className="admin-stats-note" style={{ marginTop: '0.25rem' }}>Lowercase book name used to fetch BSB passage text. E.g. colossians, ephesians, 1corinthians. Leave blank if the slug matches the book name.</p>
</div>
<div className="admin-field">
<label htmlFor={`study-status-${study.id}`}>Status</label>
<select id={`study-status-${study.id}`} value={study.status} onChange={e => updateStudyProgram(study.id, 'status', e.target.value)}>
+238 -6
View File
@@ -4119,11 +4119,11 @@
.resource-download-card {
background: #111;
border: 1px solid rgba(201, 168, 76, 0.18);
border-radius: 18px;
padding: 1.5rem;
border-radius: 14px;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.75rem;
text-decoration: none;
color: inherit;
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
@@ -4172,9 +4172,9 @@
}
.resource-link-image {
width: 80px;
height: 80px;
border-radius: 14px;
width: 64px;
height: 64px;
border-radius: 10px;
object-fit: cover;
border: 1px solid rgba(201, 168, 76, 0.2);
flex-shrink: 0;
@@ -4407,6 +4407,230 @@
padding: 0;
}
.custom-block-inline-link {
color: var(--brand-gold);
text-decoration: underline;
word-break: break-all;
}
.custom-block-link-btn {
display: inline-block;
margin-top: 1.5rem;
}
/* ── Books section ── */
.section-books {
background: #0d0d0d;
border-top: 1px solid rgba(201, 168, 76, 0.12);
padding: 4rem 0;
}
.books-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.book-card {
display: flex;
gap: 1.25rem;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(201, 168, 76, 0.1);
border-radius: 8px;
padding: 1.25rem;
}
.book-card-cover {
width: 80px;
height: 110px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.book-card-meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.book-card-title {
font-family: var(--brand-font-heading);
font-size: 1rem;
font-weight: 700;
color: var(--brand-warm-white);
line-height: 1.3;
}
.book-card-description {
font-family: var(--brand-font-body);
font-size: 0.875rem;
font-weight: 300;
color: var(--brand-muted);
line-height: 1.6;
margin: 0;
}
.book-card-btn {
align-self: flex-start;
font-size: 0.8rem;
padding: 0.4rem 0.9rem;
}
/* ── Recommended Resources section ── */
.recommended-resources-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
margin-top: 1rem;
}
.recommended-resource-card {
display: flex;
gap: 1.25rem;
align-items: flex-start;
text-decoration: none;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(201, 168, 76, 0.1);
border-radius: 8px;
padding: 1.25rem;
transition: border-color 0.2s;
}
.recommended-resource-card:hover {
border-color: rgba(201, 168, 76, 0.35);
}
.recommended-resource-image {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
.recommended-resource-meta {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.recommended-resource-title {
font-family: var(--brand-font-heading);
font-size: 1rem;
font-weight: 700;
color: var(--brand-warm-white);
}
.recommended-resource-description {
font-family: var(--brand-font-body);
font-size: 0.875rem;
font-weight: 300;
color: var(--brand-muted);
line-height: 1.6;
margin: 0;
}
.recommended-resource-action {
font-size: 0.85rem;
color: var(--brand-gold);
font-weight: 600;
}
/* ── Interior page banner ── */
.section-page-banner {
padding: 3.5rem 0 2rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.12);
}
.page-banner-inner {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.page-banner-title {
font-family: var(--brand-font-heading);
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 700;
color: var(--brand-warm-white);
margin: 0.15rem 0 0;
line-height: 1.15;
}
.page-banner-desc {
font-family: var(--brand-font-body);
font-size: 1.05rem;
font-weight: 300;
color: var(--brand-muted);
max-width: 52ch;
margin: 0.25rem 0 0;
line-height: 1.6;
}
/* ── Featured download card ── */
.resource-download-card--featured {
margin-bottom: 1.5rem;
border-color: rgba(201, 168, 76, 0.35);
background: rgba(201, 168, 76, 0.04);
}
.resource-link-image--featured {
width: 80px !important;
height: 110px !important;
border-radius: 6px !important;
object-fit: cover;
}
.featured-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--brand-gold);
background: rgba(201, 168, 76, 0.12);
border: 1px solid rgba(201, 168, 76, 0.3);
border-radius: 4px;
padding: 0.15rem 0.5rem;
margin-bottom: 0.25rem;
align-self: flex-start;
}
/* ── Books + Recommended Resources tabs ── */
.books-resources-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 2rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.15);
padding-bottom: 0;
}
.books-resources-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 0.6rem 1.1rem;
margin-bottom: -1px;
font-family: var(--brand-font-body);
font-size: 0.92rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: color 160ms ease, border-color 160ms ease;
letter-spacing: 0.02em;
}
.books-resources-tab:hover {
color: rgba(255, 255, 255, 0.85);
}
.books-resources-tab--active {
color: var(--brand-gold);
border-bottom-color: var(--brand-gold);
}
/* ── Study Guide section ── */
.section-guide {
background: #0d0d0d;
@@ -5414,6 +5638,14 @@
opacity: 0.85;
}
.admin-field-optional {
font-weight: 400;
letter-spacing: 0;
text-transform: none;
opacity: 0.6;
font-size: 0.75rem;
}
.admin-field input,
.admin-field textarea {
background: #111;
+178 -65
View File
@@ -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'
@@ -270,11 +267,27 @@ interface DownloadPageResource {
resourceId: string
amazonUrl?: string
amazonLabel?: string
isPrimaryGuide?: boolean
}
function resolveDownloadPageResource(content: SiteContent, pageId: string | undefined): DownloadPageResource | null {
if (!pageId) return null
if (pageId === 'primary-guide') {
return {
id: 'primary-guide',
label: content.studyGuideTitle || 'Companion Study Guide',
imageUrl: content.seriesImageUrl || '/images/titus-cover.png',
summary: content.studyGuideDescription || 'Complete the short form below and your download will start right away.',
tags: [],
buttonText: `Download ${content.studyGuideTitle || 'Guide'}`,
resourceId: 'primary-guide',
amazonUrl: content.studyGuideUrl,
amazonLabel: content.studyGuideAmazonButtonLabel,
isPrimaryGuide: true,
}
}
if (pageId.startsWith('custom--')) {
const customId = pageId.slice('custom--'.length)
const resource = (content.customLinks ?? []).find(link => link.id === customId && link.placement === 'resources')
@@ -546,7 +559,6 @@ function AboutSection({ content }: { content: SiteContent }) {
</div>
</div>
<div className="about-text">
<p className="eyebrow">{content.aboutShowEyebrow || 'About the Show'}</p>
<h2>{content.aboutShowHeading}</h2>
<p>{content.aboutShowP1}</p>
<p>{content.aboutShowP2}</p>
@@ -562,42 +574,6 @@ function AboutSection({ content }: { content: SiteContent }) {
)
}
function StudyGuideSection({ content }: { content: SiteContent }) {
return (
<section className="section-guide" aria-label="Companion study guide">
<div className="section-inner guide-inner">
<div className="guide-cover-art">
<img
src={content.seriesImageUrl || '/images/titus-cover.png'}
alt={content.seriesTitle}
className="guide-cover-img"
/>
</div>
<div className="guide-text">
<p className="eyebrow">Downloads</p>
<h1 className="guide-page-title">Study Guides and Downloads</h1>
<p className="guide-page-intro">Start with the primary guide below, then explore the rest of the download library further down the page.</p>
<p className="eyebrow">Free Download</p>
<h2>{content.studyGuideTitle}</h2>
<p>{content.studyGuideDescription}</p>
<StudyDownloadForm buttonText={`Download ${content.studyGuideTitle || 'Guide'}`} />
{content.studyGuideUrl && (
<div className="guide-actions">
<a
href={content.studyGuideUrl}
target="_blank"
rel="noreferrer"
className="btn-secondary"
>
{content.studyGuideAmazonButtonLabel || 'Get it on Amazon'}
</a>
</div>
)}
</div>
</div>
</section>
)
}
function ContactSection({ content }: { content: SiteContent }) {
return (
@@ -605,9 +581,6 @@ function ContactSection({ content }: { content: SiteContent }) {
<div className="section-inner contact-inner">
<div className="contact-copy">
<div className="contact-card">
<p className="eyebrow">{content.contactEyebrow || 'Get in Touch'}</p>
<h2>{content.contactHeading || 'Contact Nate'}</h2>
<div className="contact-profile">
<img
src={content.contactPhotoUrl || '/images/nate-contact-photo.png'}
@@ -719,26 +692,59 @@ function PodcastHighlightsSection({ content }: { content: SiteContent }) {
)
}
function PageBanner({ eyebrow, title, desc }: { eyebrow: string; title: string; desc?: string }) {
return (
<section className="section-page-banner" aria-label={title}>
<div className="section-inner page-banner-inner">
<p className="eyebrow">{eyebrow}</p>
<h1 className="page-banner-title">{title}</h1>
{desc && <p className="page-banner-desc">{desc}</p>}
</div>
</section>
)
}
function DownloadLibrarySection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
const [activeTag, setActiveTag] = useState<string | null>(null)
if (resources.length === 0) return null
const hasPrimaryGuide = !!(content.studyGuideTitle)
const allTags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
const filteredResources = resources.filter(r => {
return !activeTag || (r.tags ?? []).includes(activeTag)
})
if (!hasPrimaryGuide && resources.length === 0) return null
return (
<section className="section-resources section-download-library" aria-label="Download library">
<div className="section-inner">
<div className="download-library-head">
<p className="eyebrow">Download Library</p>
<h2 className="section-heading">Guides, worksheets, and study downloads.</h2>
{hasPrimaryGuide && (
<Link to="/downloads/primary-guide" className="resource-download-card resource-download-card--link resource-download-card--featured">
<div className="resource-download-header">
{(content.seriesImageUrl || '/images/titus-cover.png') && (
<img
src={content.seriesImageUrl || '/images/titus-cover.png'}
alt={content.seriesTitle}
className="resource-link-image resource-link-image--featured"
loading="lazy"
/>
)}
<div className="resource-download-meta">
<div className="featured-badge">Featured</div>
<span className="resource-link-label">{content.studyGuideTitle}</span>
{content.studyGuideDescription && (
<p className="resource-link-description">{content.studyGuideDescription}</p>
)}
<span className="resource-link-action">Download free guide </span>
</div>
</div>
</Link>
)}
{resources.length > 0 && (
<>
{allTags.length > 0 && (
<div className="download-filter-bar">
<div className="download-tag-filter">
@@ -773,7 +779,6 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
)}
{filteredResources.length > 0 ? (
<div className="download-library-group">
<div className="resources-list">
{filteredResources.map(resource => (
<Link key={resource.id} to={`/downloads/${buildCustomDownloadPageId(resource.id)}`} className="resource-download-card resource-download-card--link">
@@ -793,10 +798,11 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
</Link>
))}
</div>
</div>
) : (
<p className="download-empty-state">No downloads tagged "{activeTag}".</p>
)}
</>
)}
</div>
</section>
)
@@ -837,7 +843,9 @@ function DownloadDetailPage({ content }: { content: SiteContent }) {
)}
<div className="download-detail-form-wrap">
<ResourceDownloadForm resourceId={resource.resourceId} buttonText={resource.buttonText} />
{resource.isPrimaryGuide
? <StudyDownloadForm buttonText={resource.buttonText} />
: <ResourceDownloadForm resourceId={resource.resourceId} buttonText={resource.buttonText} />}
</div>
<div className="download-detail-actions">
@@ -888,6 +896,16 @@ function ShareShowButton() {
)
}
function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s]+)/g
const parts = text.split(urlRegex)
return parts.map((part, i) =>
urlRegex.test(part)
? <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="custom-block-inline-link">{part}</a>
: part
)
}
function CustomBlocksSection({ content, page }: { content: SiteContent; page: string }) {
const blocks = (content.customBlocks ?? []).filter(block => {
const blockPage = block.page ?? 'downloads'
@@ -900,7 +918,12 @@ function CustomBlocksSection({ content, page }: { content: SiteContent; page: st
<section key={block.id} className="section-custom-block" aria-label={block.heading}>
<div className="section-inner custom-block-inner">
<h2>{block.heading}</h2>
<p>{block.body}</p>
<p>{linkifyText(block.body)}</p>
{block.linkUrl && (
<a href={block.linkUrl} target="_blank" rel="noopener noreferrer" className="btn-primary custom-block-link-btn">
{block.linkLabel || 'Learn More'}
</a>
)}
</div>
</section>
))}
@@ -1454,16 +1477,9 @@ function FinishedBooksPage({ content }: { content: SiteContent }) {
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Studies" title="Finished Books" desc="Every series we've completed — with a full ordered episode playlist for each book of the Bible." />
<section className="section-finished-books">
<div className="section-inner">
<div className="finished-books-header">
<h1 className="section-heading">
<span className="ornament"></span> Finished Books <span className="ornament"></span>
</h1>
<p className="finished-books-intro">
Every series we've completed with a full ordered episode playlist for each book of the Bible.
</p>
</div>
{books.length === 0 ? (
<div className="finished-books-empty-state">
<div className="finished-books-empty-icon">📖</div>
@@ -1703,6 +1719,7 @@ function EpisodesPage({ content }: { content: SiteContent }) {
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Episodes" title="Podcast Episodes" desc={content.episodesSeoIntro || 'Expository Bible teaching — one verse at a time.'} />
<section className="section-player" aria-label="Current series episodes">
<div className="section-inner">
@@ -1726,6 +1743,102 @@ function EpisodesPage({ content }: { content: SiteContent }) {
)
}
function BooksAndResourcesSection({ content }: { content: SiteContent }) {
const books = (content.customLinks ?? []).filter(link => link.placement === 'books')
const recommended = (content.customLinks ?? []).filter(link => link.placement === 'recommended')
const [activeTab, setActiveTab] = useState<'books' | 'recommended'>('books')
const hasBooks = books.length > 0
const hasRecommended = recommended.length > 0
const showTabs = hasBooks && hasRecommended
if (!hasBooks && !hasRecommended) return <TruthForLifeWidget type="resourcead" />
const tab = showTabs ? activeTab : (hasBooks ? 'books' : 'recommended')
return (
<section className="section-resources section-books-resources" aria-label="Books and recommended resources">
<div className="section-inner">
{showTabs ? (
<div className="books-resources-tabs">
<button
type="button"
className={`books-resources-tab${tab === 'books' ? ' books-resources-tab--active' : ''}`}
onClick={() => setActiveTab('books')}
>
Reading List
</button>
<button
type="button"
className={`books-resources-tab${tab === 'recommended' ? ' books-resources-tab--active' : ''}`}
onClick={() => setActiveTab('recommended')}
>
Recommended Resources
</button>
</div>
) : (
<div className="download-library-head">
<p className="eyebrow">{tab === 'books' ? 'Reading List' : 'Recommended'}</p>
<h2 className="section-heading">{tab === 'books' ? 'Books worth your time.' : 'Resources worth your time.'}</h2>
</div>
)}
{tab === 'books' && (
<>
{showTabs && (
<div className="download-library-head" style={{ marginBottom: '1.5rem' }}>
<h2 className="section-heading">Books worth your time.</h2>
</div>
)}
<div className="books-list">
{books.map(book => (
<div key={book.id} className="book-card">
{book.imageUrl && (
<img src={book.imageUrl} alt={book.label} className="book-card-cover" loading="lazy" />
)}
<div className="book-card-meta">
<span className="book-card-title">{book.label}</span>
{book.description && <p className="book-card-description">{book.description}</p>}
{(book.amazonUrl || book.url) && (
<a href={book.amazonUrl || book.url} target="_blank" rel="noopener noreferrer" className="btn-primary book-card-btn">
{book.amazonUrl ? (book.amazonLabel || 'View on Amazon') : (book.amazonLabel || 'Learn More')}
</a>
)}
</div>
</div>
))}
</div>
</>
)}
{tab === 'recommended' && (
<>
{showTabs && (
<div className="download-library-head" style={{ marginBottom: '1.5rem' }}>
<h2 className="section-heading">Recommended Resources</h2>
</div>
)}
<div className="recommended-resources-list">
{recommended.map(item => (
<a key={item.id} href={item.url} target="_blank" rel="noopener noreferrer" className="recommended-resource-card">
{item.imageUrl && (
<img src={item.imageUrl} alt={item.label} className="recommended-resource-image" loading="lazy" />
)}
<div className="recommended-resource-meta">
<span className="recommended-resource-title">{item.label}</span>
{item.description && <p className="recommended-resource-description">{item.description}</p>}
<span className="recommended-resource-action">Learn more </span>
</div>
</a>
))}
</div>
</>
)}
</div>
</section>
)
}
function ResourcesPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
@@ -1736,10 +1849,10 @@ function ResourcesPage({ content }: { content: SiteContent }) {
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<StudyGuideSection content={content} />
<PageBanner eyebrow="Downloads" title="Study Guides & Resources" desc="Free guides, worksheets, and curated resources to help you go deeper in Scripture." />
<DownloadLibrarySection content={content} />
<CustomBlocksSection content={content} page="downloads" />
<TruthForLifeWidget type="resourcead" />
<BooksAndResourcesSection content={content} />
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
@@ -1756,6 +1869,7 @@ function AboutPage({ content }: { content: SiteContent }) {
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="About" title="About the Show" />
<AboutSection content={content} />
<TestimonialsSection testimonials={content.testimonials ?? []} />
<CustomBlocksSection content={content} page="about" />
@@ -1770,6 +1884,7 @@ function ContactPage({ content }: { content: SiteContent }) {
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Contact" title={content.contactHeading || 'Get in Touch'} />
<ContactSection content={content} />
<CustomBlocksSection content={content} page="contact" />
<SiteFooter content={content} />
@@ -2291,6 +2406,7 @@ function QuestionsPage({ content }: { content: SiteContent }) {
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Q&A" title="Questions & Answers" desc="Real questions answered from Scripture — browse by topic or submit your own." />
<QASection />
<CustomBlocksSection content={content} page="questions" />
<SiteFooter content={content} />
@@ -2497,9 +2613,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"
-1080
View File
File diff suppressed because it is too large Load Diff
-897
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+28 -1
View File
@@ -1506,6 +1506,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
const [bannerDismissed, setBannerDismissed] = useState(() => localStorage.getItem('study-banner-dismissed-v1') === '1')
const [autoSaveMsg, setAutoSaveMsg] = useState('')
const [_checkpointReflection, _setCheckpointReflection] = useState('')
const [bsbPassage, setBsbPassage] = useState<string | null>(null)
const [bsbPassageLoading, setBsbPassageLoading] = useState(false)
const savedNoteCount = Object.values(noteMap).filter(v => v?.trim()).length
const lessonAudioEmbedUrl = useMemo(() => {
@@ -1546,6 +1548,21 @@ export function ColossiansStudySectionPage({ content }: Props) {
}
}, [])
useEffect(() => {
if (!section?.reference || !currentStudySlug) return
let cancelled = false
setBsbPassage(null)
setBsbPassageLoading(true)
const bookKey = (study?.bibleBook?.trim() || currentStudySlug).toLowerCase()
const params = new URLSearchParams({ book: bookKey, reference: section.reference })
fetch(`/api/bible-passage?${params}`)
.then(r => r.json() as Promise<{ text?: string }>)
.then(data => { if (!cancelled && data.text) setBsbPassage(data.text) })
.catch(() => {})
.finally(() => { if (!cancelled) setBsbPassageLoading(false) })
return () => { cancelled = true }
}, [section?.id, section?.reference, currentStudySlug, study?.bibleBook])
useEffect(() => {
if (!auth.checked || !auth.authenticated || !study || !isEnrolled) return
let cancelled = false
@@ -1908,7 +1925,17 @@ export function ColossiansStudySectionPage({ content }: Props) {
></button>
)}
<h2>Scripture Text</h2>
<p className="study-detail-copy study-detail-copy--scripture">{section.passageText || `Add the passage text for ${section.reference} here when you move the guide online.`}</p>
{bsbPassageLoading
? <p className="study-detail-copy" style={{ opacity: 0.5 }}>Loading passage</p>
: (bsbPassage ?? section.passageText)
? (bsbPassage ?? section.passageText).split('\n\n').map((para, i) => (
<p key={i} className="study-detail-copy study-detail-copy--scripture">
{para.split('\n').map((line, j, arr) => j < arr.length - 1 ? <>{line}<br /></> : line)}
</p>
))
: <p className="study-detail-copy" style={{ opacity: 0.5 }}>Passage text not available.</p>
}
{bsbPassage && <p style={{ fontSize: '0.78rem', color: '#5a5440', marginTop: '0.5rem' }}>Berean Standard Bible (BSB)</p>}
{isEnrolled && openAnchor === 'scripture' && (
<NoteCard
anchorLabel="Scripture Text"
+16
View File
@@ -78,6 +78,7 @@ export function AnalyticsPanel({
onDeployHook,
onRefreshStatus,
}: Props) {
const [panelTab, setPanelTab] = useState<'traffic' | 'engagement' | 'system'>('traffic')
const [chartTab, setChartTab] = useState<'overview' | 'breakdown' | 'geographic' | 'referrers'>('overview')
const [expandedVisitor, setExpandedVisitor] = useState<string | null>(null)
const [fullBackupFile, setFullBackupFile] = useState<File | null>(null)
@@ -245,6 +246,14 @@ export function AnalyticsPanel({
<p>Real-time insights into site traffic, bot detection, visitor behavior, and data management.</p>
</div>
{/* Top-level panel tabs */}
<div className="admin-tabs admin-tabs--charts" style={{ marginBottom: '1.5rem' }}>
<button type="button" className={`admin-tab${panelTab === 'traffic' ? ' admin-tab--active' : ''}`} onClick={() => setPanelTab('traffic')}>Traffic</button>
<button type="button" className={`admin-tab${panelTab === 'engagement' ? ' admin-tab--active' : ''}`} onClick={() => setPanelTab('engagement')}>Engagement</button>
<button type="button" className={`admin-tab${panelTab === 'system' ? ' admin-tab--active' : ''}`} onClick={() => setPanelTab('system')}>System</button>
</div>
{panelTab === 'traffic' && (<>
{/* Time-range tabs */}
{onRangeChange && (
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
@@ -523,6 +532,9 @@ export function AnalyticsPanel({
)}
</div>
</>)}
{panelTab === 'engagement' && (<>
{/* Episode Plays */}
{stats.episodePlays && stats.episodePlays.length > 0 && (
<>
@@ -790,6 +802,9 @@ export function AnalyticsPanel({
</>
)}
</>)}
{panelTab === 'system' && (<>
{/* Deployment & Cache Status */}
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Deployment &amp; Cache Status</h2>
@@ -872,6 +887,7 @@ export function AnalyticsPanel({
</button>
</div>
{maintenanceMsg && <p className="admin-stats-note">{maintenanceMsg}</p>}
</>)}
</section>
)
}
-4
View File
@@ -783,10 +783,6 @@ export default function QASection() {
return (
<section id="qa" className="section-qa" aria-label="Questions and answers">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> Questions & Answers <span className="ornament"></span>
</h2>
<div className="qa-submit-cta">
<p className="qa-submit-cta-text">Have a question about Scripture or the podcast?</p>
<Link to="/contact" className="btn-primary">Submit a Question </Link>
+4 -1
View File
@@ -9,6 +9,7 @@ export interface StudyProgram {
slug: string
title: string
description: string
bibleBook?: string
homepageEyebrow?: string
showOnHomepage?: boolean
showNewTag?: boolean
@@ -25,7 +26,7 @@ export interface CustomLink {
id: string
label: string
url: string
placement: 'platforms' | 'footer' | 'resources' | 'otherSites' | 'externalSites'
placement: 'platforms' | 'footer' | 'resources' | 'otherSites' | 'externalSites' | 'books' | 'recommended'
imageUrl?: string
description?: string
amazonUrl?: string
@@ -38,6 +39,8 @@ export interface CustomBlock {
heading: string
body: string
page?: 'homepage' | 'start-here' | 'episodes' | 'downloads' | 'about' | 'contact' | 'questions'
linkUrl?: string
linkLabel?: string
}
export interface WhereToNextCard {