Compare commits
15 Commits
bf3154378b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 53405dad9b | |||
| 2d1c28b467 | |||
| a4a1fdd891 | |||
| 6d4bcb2ae2 | |||
| 293d9bee4e | |||
| 0512d40fc6 | |||
| 0fdf7f0f50 | |||
| 005da43738 | |||
| 24d7fd7cec | |||
| 889baf23ca | |||
| eb1d3ddd9c | |||
| a35e04320a | |||
| 2ba9c9f7f4 | |||
| 9e602e29ec | |||
| 6acc3f1d04 |
@@ -36,18 +36,18 @@ jobs:
|
|||||||
- name: Run production build
|
- name: Run production build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: git.necloud.us
|
||||||
username: ${{ github.actor }}
|
username: nate@versebyversewithnate.us
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata
|
- name: Extract metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/nmemmert/siteforge
|
images: git.necloud.us/nate/siteforge
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
type=sha
|
type=sha
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
},
|
},
|
||||||
"timeOnPage": {
|
"timeOnPage": {
|
||||||
"/": {
|
"/": {
|
||||||
"totalSeconds": 9,
|
"totalSeconds": 51,
|
||||||
"count": 2
|
"count": 4
|
||||||
},
|
},
|
||||||
"/episodes": {
|
"/episodes": {
|
||||||
"totalSeconds": 318967,
|
"totalSeconds": 318967,
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"outboundClicks": {},
|
"outboundClicks": {},
|
||||||
|
"linkClicks": {},
|
||||||
"utmSources": {},
|
"utmSources": {},
|
||||||
"searchQueries": {},
|
"searchQueries": {},
|
||||||
"notFound": {},
|
"notFound": {},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"users": {},
|
"users": {},
|
||||||
"updatedAt": "2026-08-10T12:37:17.805Z"
|
"updatedAt": "2026-08-10T14:22:31.192Z"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.33",
|
"version": "1.1.40",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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 registerEpisodes } from './server/routes/episodes.js'
|
||||||
import { register as registerFeed } from './server/routes/feed.js'
|
import { register as registerFeed } from './server/routes/feed.js'
|
||||||
import { register as registerQrCodes } from './server/routes/qr-codes.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'
|
import { register as registerPublic } from './server/routes/public.js'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
@@ -110,6 +111,7 @@ registerDownloads(app)
|
|||||||
registerEpisodes(app)
|
registerEpisodes(app)
|
||||||
registerFeed(app)
|
registerFeed(app)
|
||||||
registerQrCodes(app)
|
registerQrCodes(app)
|
||||||
|
registerBiblePassage(app)
|
||||||
|
|
||||||
// Hit-counting middleware (must come before public routes)
|
// Hit-counting middleware (must come before public routes)
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
|||||||
+5
-1
@@ -167,6 +167,8 @@ function sanitizeCustomLinks(value) {
|
|||||||
|| item?.placement === 'resources'
|
|| item?.placement === 'resources'
|
||||||
|| item?.placement === 'otherSites'
|
|| item?.placement === 'otherSites'
|
||||||
|| item?.placement === 'externalSites'
|
|| item?.placement === 'externalSites'
|
||||||
|
|| item?.placement === 'books'
|
||||||
|
|| item?.placement === 'recommended'
|
||||||
? item.placement
|
? item.placement
|
||||||
: 'footer'
|
: 'footer'
|
||||||
|
|
||||||
@@ -184,7 +186,7 @@ function sanitizeCustomLinks(value) {
|
|||||||
placement,
|
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) {
|
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'
|
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
|
? item.page
|
||||||
: 'downloads',
|
: 'downloads',
|
||||||
|
linkUrl: sanitizeUrl(item.linkUrl),
|
||||||
|
linkLabel: typeof item.linkLabel === 'string' ? item.linkLabel.trim().slice(0, 120) : '',
|
||||||
}))
|
}))
|
||||||
.filter(item => item.heading || item.body)
|
.filter(item => item.heading || item.body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,6 +253,10 @@ export function register(app) {
|
|||||||
const botReasons = Object.entries(state.hitStats.botReasons ?? {})
|
const botReasons = Object.entries(state.hitStats.botReasons ?? {})
|
||||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([reason, count]) => ({ reason, count }))
|
.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 recentVisitorRows = rangeVisitorRows.slice(0, 100).map(row => {
|
||||||
const fullVisitor = state.visitorStats.visitors[row.visitorId]
|
const fullVisitor = state.visitorStats.visitors[row.visitorId]
|
||||||
return { ...row, pageHistory: fullVisitor?.pageHistory ?? [] }
|
return { ...row, pageHistory: fullVisitor?.pageHistory ?? [] }
|
||||||
@@ -315,13 +319,13 @@ export function register(app) {
|
|||||||
returningVisits: state.visitorStats.returningVisits,
|
returningVisits: state.visitorStats.returningVisits,
|
||||||
firstVisitAt: state.visitorStats.firstVisitAt,
|
firstVisitAt: state.visitorStats.firstVisitAt,
|
||||||
lastVisitAt: state.visitorStats.lastVisitAt,
|
lastVisitAt: state.visitorStats.lastVisitAt,
|
||||||
topCountries: buildTopLocations(recentVisitorRows, 'country'),
|
topCountries: buildTopLocations(allRangeRows, 'country'),
|
||||||
topStates: buildTopLocations(recentVisitorRows, 'state'),
|
topStates: buildTopLocations(allRangeRows, 'state'),
|
||||||
topCounties: buildTopLocations(recentVisitorRows, 'county'),
|
topCounties: buildTopLocations(allRangeRows, 'county'),
|
||||||
topCities: buildTopLocations(recentVisitorRows, 'city'),
|
topCities: buildTopLocations(allRangeRows, 'city'),
|
||||||
deviceBreakdown: (() => {
|
deviceBreakdown: (() => {
|
||||||
const counts = { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
|
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'
|
const d = row.device ?? 'unknown'
|
||||||
counts[d] = (counts[d] ?? 0) + 1
|
counts[d] = (counts[d] ?? 0) + 1
|
||||||
}
|
}
|
||||||
@@ -329,7 +333,7 @@ export function register(app) {
|
|||||||
})(),
|
})(),
|
||||||
topReferrers: (() => {
|
topReferrers: (() => {
|
||||||
const counts = {}
|
const counts = {}
|
||||||
for (const row of recentVisitorRows) {
|
for (const row of allRangeRows) {
|
||||||
if (!row.referrer) continue
|
if (!row.referrer) continue
|
||||||
counts[row.referrer] = (counts[row.referrer] ?? 0) + 1
|
counts[row.referrer] = (counts[row.referrer] ?? 0) + 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+161
-457
@@ -21,6 +21,28 @@ interface SortableLessonSectionProps {
|
|||||||
|
|
||||||
function SortableLessonSection({ section, study, updateStudySection, removeStudySection, rssEpisodes }: SortableLessonSectionProps) {
|
function SortableLessonSection({ section, study, updateStudySection, removeStudySection, rssEpisodes }: SortableLessonSectionProps) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id })
|
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 = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
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)} />
|
<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>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`study-section-passage-${study.id}-${section.id}`}>Passage Text</label>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '0.35rem' }}>
|
||||||
<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)} />
|
<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>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`study-section-commentary-${study.id}-${section.id}`}>Commentary</label>
|
<label htmlFor={`study-section-commentary-${study.id}-${section.id}`}>Commentary</label>
|
||||||
@@ -665,35 +693,13 @@ interface Subscriber {
|
|||||||
source: 'contact-form' | 'download'
|
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 StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'redirects' | 'podcastFeaturedLinks' | 'finishedBooks' | 'testimonials' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
|
||||||
|
|
||||||
type AdminView =
|
type AdminView =
|
||||||
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
|
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
|
||||||
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist'
|
| 'podcast' | 'current-series' | 'episode-highlights'
|
||||||
| 'downloads' | 'custom-links' | 'content-blocks'
|
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||||
| 'subscribers' | 'study-users' | 'email-templates'
|
| 'subscribers' | 'study-users' | 'email-templates'
|
||||||
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
|
| '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: 'downloads', label: 'Downloads' },
|
||||||
{ value: 'custom-links', label: 'Custom Links' },
|
{ value: 'custom-links', label: 'Custom Links' },
|
||||||
{ value: 'content-blocks', label: 'Content Blocks' },
|
{ 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'
|
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' | 'email-templates'
|
||||||
|
|
||||||
@@ -1175,10 +1183,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
|
|
||||||
const [rssEpisodes, setRssEpisodes] = useState<Array<{ title: string; audioUrl: string; duration: string; episode: string }>>([])
|
const [rssEpisodes, setRssEpisodes] = useState<Array<{ title: string; audioUrl: string; duration: string; episode: string }>>([])
|
||||||
|
|
||||||
const [podcastChecklist, setPodcastChecklist] = useState<PodcastChecklistData>({ tasks: [], episodes: [] })
|
const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||||
const [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
|
||||||
const [podcastChecklistMsg, setPodcastChecklistMsg] = useState('')
|
|
||||||
const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|
||||||
const [manualQuestion, setManualQuestion] = useState({
|
const [manualQuestion, setManualQuestion] = useState({
|
||||||
firstName: '',
|
firstName: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -1395,16 +1400,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
.then(r => (r.ok ? r.json() : Promise.reject()))
|
.then(r => (r.ok ? r.json() : Promise.reject()))
|
||||||
.then(data => setDownloadStats((data as { counts: Record<string, number> }).counts ?? {}))
|
.then(data => setDownloadStats((data as { counts: Record<string, number> }).counts ?? {}))
|
||||||
.catch(() => {})
|
.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(() => {
|
useEffect(() => {
|
||||||
@@ -1713,150 +1708,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
setForm(f => ({ ...f, podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).filter(link => link.id !== id) }))
|
setForm(f => ({ ...f, podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).filter(link => link.id !== id) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function addChecklistTask(phase: ChecklistPhase) {
|
async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||||
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]
|
const file = event.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
@@ -2146,11 +1998,15 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
if (placement === 'footer') return 'Footer navigation links'
|
if (placement === 'footer') return 'Footer navigation links'
|
||||||
if (placement === 'otherSites') return 'Other site links in footer dropdown'
|
if (placement === 'otherSites') return 'Other site links in footer dropdown'
|
||||||
if (placement === 'externalSites') return 'Homepage external site links'
|
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)'
|
return 'Downloads page (More Resources)'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCustomLinkPreviewPath(placement: CustomLink['placement']) {
|
function getCustomLinkPreviewPath(placement: CustomLink['placement']) {
|
||||||
if (placement === 'resources') return '/resources'
|
if (placement === 'resources') return '/resources'
|
||||||
|
if (placement === 'books') return '/resources'
|
||||||
|
if (placement === 'recommended') return '/resources'
|
||||||
return '/'
|
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[]) {
|
function updateLink(id: string, field: keyof CustomLink, value: string | string[]) {
|
||||||
setForm(f => ({
|
setForm(f => ({
|
||||||
...f,
|
...f,
|
||||||
@@ -2745,67 +2621,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
questionPage * QUESTION_PAGE_SIZE,
|
questionPage * QUESTION_PAGE_SIZE,
|
||||||
(questionPage + 1) * 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() {
|
function renderSaveStatus() {
|
||||||
return (
|
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 (
|
return (
|
||||||
<div className="admin-shell">
|
<div className="admin-shell">
|
||||||
{/* ── Top bar ── */}
|
{/* ── Top bar ── */}
|
||||||
@@ -3507,8 +3313,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
<button type="button" className={`admin-tab${podcastTab === 'current-series' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('current-series')}>Current Series</button>
|
<button type="button" className={`admin-tab${podcastTab === '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 === '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 === 'finished-books' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('finished-books')}>Finished Books</button>
|
||||||
<button type="button" className={`admin-tab${podcastTab === 'podcast-checklist' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('podcast-checklist')}>Production Checklist</button>
|
<button type="button" className={`admin-tab${podcastTab === 'episode-scripts' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-scripts')}>Episode Scripts</button>
|
||||||
<button type="button" className={`admin-tab${podcastTab === 'episode-scripts' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-scripts')}>Episode Scripts</button>
|
|
||||||
<button type="button" className={`admin-tab${podcastTab === 'rss-feed' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('rss-feed')}>RSS Feed</button>
|
<button type="button" className={`admin-tab${podcastTab === 'rss-feed' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('rss-feed')}>RSS Feed</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -3710,206 +3515,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
</section>
|
</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 */}
|
{/* DOWNLOADS */}
|
||||||
{adminView === 'downloads' && (
|
{adminView === 'downloads' && (
|
||||||
<section className="admin-panel-section">
|
<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>
|
<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)} />
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
<button type="button" className="btn-admin-remove" onClick={() => removeBlock(block.id)}>Remove</button>
|
<button type="button" className="btn-admin-remove" onClick={() => removeBlock(block.id)}>Remove</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -4258,6 +3871,92 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
</section>
|
</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 */}
|
{/* STUDIES */}
|
||||||
{adminView === 'colossians-study' && (
|
{adminView === 'colossians-study' && (
|
||||||
<section className="admin-panel-section">
|
<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>
|
<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, '-'))} />
|
<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>
|
||||||
|
<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">
|
<div className="admin-field">
|
||||||
<label htmlFor={`study-status-${study.id}`}>Status</label>
|
<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)}>
|
<select id={`study-status-${study.id}`} value={study.status} onChange={e => updateStudyProgram(study.id, 'status', e.target.value)}>
|
||||||
|
|||||||
+238
-6
@@ -4119,11 +4119,11 @@
|
|||||||
.resource-download-card {
|
.resource-download-card {
|
||||||
background: #111;
|
background: #111;
|
||||||
border: 1px solid rgba(201, 168, 76, 0.18);
|
border: 1px solid rgba(201, 168, 76, 0.18);
|
||||||
border-radius: 18px;
|
border-radius: 14px;
|
||||||
padding: 1.5rem;
|
padding: 1rem 1.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
|
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
|
||||||
@@ -4172,9 +4172,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.resource-link-image {
|
.resource-link-image {
|
||||||
width: 80px;
|
width: 64px;
|
||||||
height: 80px;
|
height: 64px;
|
||||||
border-radius: 14px;
|
border-radius: 10px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 1px solid rgba(201, 168, 76, 0.2);
|
border: 1px solid rgba(201, 168, 76, 0.2);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -4407,6 +4407,230 @@
|
|||||||
padding: 0;
|
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 ── */
|
/* ── Study Guide section ── */
|
||||||
.section-guide {
|
.section-guide {
|
||||||
background: #0d0d0d;
|
background: #0d0d0d;
|
||||||
@@ -5414,6 +5638,14 @@
|
|||||||
opacity: 0.85;
|
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 input,
|
||||||
.admin-field textarea {
|
.admin-field textarea {
|
||||||
background: #111;
|
background: #111;
|
||||||
|
|||||||
+229
-116
@@ -2,9 +2,6 @@ import { useState, useEffect, useRef } from 'react'
|
|||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react'
|
||||||
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
import AdminPage from './AdminPage'
|
import AdminPage from './AdminPage'
|
||||||
import EmailShell from './EmailPage'
|
|
||||||
import ContactsShell from './ContactsPage'
|
|
||||||
import CalendarShell from './CalendarPage'
|
|
||||||
import QASection from './components/QASection'
|
import QASection from './components/QASection'
|
||||||
import ContactForm from './components/ContactForm'
|
import ContactForm from './components/ContactForm'
|
||||||
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy'
|
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy'
|
||||||
@@ -270,11 +267,27 @@ interface DownloadPageResource {
|
|||||||
resourceId: string
|
resourceId: string
|
||||||
amazonUrl?: string
|
amazonUrl?: string
|
||||||
amazonLabel?: string
|
amazonLabel?: string
|
||||||
|
isPrimaryGuide?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDownloadPageResource(content: SiteContent, pageId: string | undefined): DownloadPageResource | null {
|
function resolveDownloadPageResource(content: SiteContent, pageId: string | undefined): DownloadPageResource | null {
|
||||||
if (!pageId) return 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--')) {
|
if (pageId.startsWith('custom--')) {
|
||||||
const customId = pageId.slice('custom--'.length)
|
const customId = pageId.slice('custom--'.length)
|
||||||
const resource = (content.customLinks ?? []).find(link => link.id === customId && link.placement === 'resources')
|
const resource = (content.customLinks ?? []).find(link => link.id === customId && link.placement === 'resources')
|
||||||
@@ -546,7 +559,6 @@ function AboutSection({ content }: { content: SiteContent }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="about-text">
|
<div className="about-text">
|
||||||
<p className="eyebrow">{content.aboutShowEyebrow || 'About the Show'}</p>
|
|
||||||
<h2>{content.aboutShowHeading}</h2>
|
<h2>{content.aboutShowHeading}</h2>
|
||||||
<p>{content.aboutShowP1}</p>
|
<p>{content.aboutShowP1}</p>
|
||||||
<p>{content.aboutShowP2}</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 }) {
|
function ContactSection({ content }: { content: SiteContent }) {
|
||||||
return (
|
return (
|
||||||
@@ -605,9 +581,6 @@ function ContactSection({ content }: { content: SiteContent }) {
|
|||||||
<div className="section-inner contact-inner">
|
<div className="section-inner contact-inner">
|
||||||
<div className="contact-copy">
|
<div className="contact-copy">
|
||||||
<div className="contact-card">
|
<div className="contact-card">
|
||||||
<p className="eyebrow">{content.contactEyebrow || 'Get in Touch'}</p>
|
|
||||||
<h2>{content.contactHeading || 'Contact Nate'}</h2>
|
|
||||||
|
|
||||||
<div className="contact-profile">
|
<div className="contact-profile">
|
||||||
<img
|
<img
|
||||||
src={content.contactPhotoUrl || '/images/nate-contact-photo.png'}
|
src={content.contactPhotoUrl || '/images/nate-contact-photo.png'}
|
||||||
@@ -719,83 +692,116 @@ 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 }) {
|
function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
||||||
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
|
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
|
||||||
const [activeTag, setActiveTag] = useState<string | null>(null)
|
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 allTags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
|
||||||
|
|
||||||
const filteredResources = resources.filter(r => {
|
const filteredResources = resources.filter(r => {
|
||||||
return !activeTag || (r.tags ?? []).includes(activeTag)
|
return !activeTag || (r.tags ?? []).includes(activeTag)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!hasPrimaryGuide && resources.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="section-resources section-download-library" aria-label="Download library">
|
<section className="section-resources section-download-library" aria-label="Download library">
|
||||||
<div className="section-inner">
|
<div className="section-inner">
|
||||||
<div className="download-library-head">
|
{hasPrimaryGuide && (
|
||||||
<p className="eyebrow">Download Library</p>
|
<Link to="/downloads/primary-guide" className="resource-download-card resource-download-card--link resource-download-card--featured">
|
||||||
<h2 className="section-heading">Guides, worksheets, and study downloads.</h2>
|
<div className="resource-download-header">
|
||||||
</div>
|
{(content.seriesImageUrl || '/images/titus-cover.png') && (
|
||||||
|
<img
|
||||||
{allTags.length > 0 && (
|
src={content.seriesImageUrl || '/images/titus-cover.png'}
|
||||||
<div className="download-filter-bar">
|
alt={content.seriesTitle}
|
||||||
<div className="download-tag-filter">
|
className="resource-link-image resource-link-image--featured"
|
||||||
<button
|
loading="lazy"
|
||||||
type="button"
|
/>
|
||||||
className={`download-tag-chip${activeTag === null ? ' download-tag-chip--active' : ''}`}
|
|
||||||
onClick={() => setActiveTag(null)}
|
|
||||||
>
|
|
||||||
All topics
|
|
||||||
</button>
|
|
||||||
{allTags.map(tag => (
|
|
||||||
<button
|
|
||||||
key={tag}
|
|
||||||
type="button"
|
|
||||||
className={`download-tag-chip${activeTag === tag ? ' download-tag-chip--active' : ''}`}
|
|
||||||
onClick={() => setActiveTag(prev => prev === tag ? null : tag)}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{activeTag !== null && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="download-clear-link"
|
|
||||||
onClick={() => setActiveTag(null)}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredResources.length > 0 ? (
|
{resources.length > 0 && (
|
||||||
<div className="download-library-group">
|
<>
|
||||||
<div className="resources-list">
|
{allTags.length > 0 && (
|
||||||
{filteredResources.map(resource => (
|
<div className="download-filter-bar">
|
||||||
<Link key={resource.id} to={`/downloads/${buildCustomDownloadPageId(resource.id)}`} className="resource-download-card resource-download-card--link">
|
<div className="download-tag-filter">
|
||||||
<div className="resource-download-header">
|
<button
|
||||||
{resource.imageUrl && (
|
type="button"
|
||||||
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" loading="lazy" />
|
className={`download-tag-chip${activeTag === null ? ' download-tag-chip--active' : ''}`}
|
||||||
)}
|
onClick={() => setActiveTag(null)}
|
||||||
<div className="resource-download-meta">
|
>
|
||||||
<span className="resource-link-label">{resource.label}</span>
|
All topics
|
||||||
{resource.description && <p className="resource-link-description">{resource.description}</p>}
|
</button>
|
||||||
{(resource.tags ?? []).length > 0 && (
|
{allTags.map(tag => (
|
||||||
<div className="resource-link-tags">{(resource.tags ?? []).join(', ')}</div>
|
<button
|
||||||
|
key={tag}
|
||||||
|
type="button"
|
||||||
|
className={`download-tag-chip${activeTag === tag ? ' download-tag-chip--active' : ''}`}
|
||||||
|
onClick={() => setActiveTag(prev => prev === tag ? null : tag)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{activeTag !== null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="download-clear-link"
|
||||||
|
onClick={() => setActiveTag(null)}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredResources.length > 0 ? (
|
||||||
|
<div className="resources-list">
|
||||||
|
{filteredResources.map(resource => (
|
||||||
|
<Link key={resource.id} to={`/downloads/${buildCustomDownloadPageId(resource.id)}`} className="resource-download-card resource-download-card--link">
|
||||||
|
<div className="resource-download-header">
|
||||||
|
{resource.imageUrl && (
|
||||||
|
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" loading="lazy" />
|
||||||
)}
|
)}
|
||||||
<span className="resource-link-action">Open download page →</span>
|
<div className="resource-download-meta">
|
||||||
|
<span className="resource-link-label">{resource.label}</span>
|
||||||
|
{resource.description && <p className="resource-link-description">{resource.description}</p>}
|
||||||
|
{(resource.tags ?? []).length > 0 && (
|
||||||
|
<div className="resource-link-tags">{(resource.tags ?? []).join(', ')}</div>
|
||||||
|
)}
|
||||||
|
<span className="resource-link-action">Open download page →</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
</Link>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
</div>
|
<p className="download-empty-state">No downloads tagged "{activeTag}".</p>
|
||||||
) : (
|
)}
|
||||||
<p className="download-empty-state">No downloads tagged "{activeTag}".</p>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -837,7 +843,9 @@ function DownloadDetailPage({ content }: { content: SiteContent }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="download-detail-form-wrap">
|
<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>
|
||||||
|
|
||||||
<div className="download-detail-actions">
|
<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 }) {
|
function CustomBlocksSection({ content, page }: { content: SiteContent; page: string }) {
|
||||||
const blocks = (content.customBlocks ?? []).filter(block => {
|
const blocks = (content.customBlocks ?? []).filter(block => {
|
||||||
const blockPage = block.page ?? 'downloads'
|
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}>
|
<section key={block.id} className="section-custom-block" aria-label={block.heading}>
|
||||||
<div className="section-inner custom-block-inner">
|
<div className="section-inner custom-block-inner">
|
||||||
<h2>{block.heading}</h2>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
@@ -1454,16 +1477,9 @@ function FinishedBooksPage({ content }: { content: SiteContent }) {
|
|||||||
<div className="site">
|
<div className="site">
|
||||||
<SiteHeader content={content} />
|
<SiteHeader content={content} />
|
||||||
<SiteSearchBar 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">
|
<section className="section-finished-books">
|
||||||
<div className="section-inner">
|
<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 ? (
|
{books.length === 0 ? (
|
||||||
<div className="finished-books-empty-state">
|
<div className="finished-books-empty-state">
|
||||||
<div className="finished-books-empty-icon">📖</div>
|
<div className="finished-books-empty-icon">📖</div>
|
||||||
@@ -1703,6 +1719,7 @@ function EpisodesPage({ content }: { content: SiteContent }) {
|
|||||||
<div className="site">
|
<div className="site">
|
||||||
<SiteHeader content={content} />
|
<SiteHeader content={content} />
|
||||||
<SiteSearchBar 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">
|
<section className="section-player" aria-label="Current series episodes">
|
||||||
<div className="section-inner">
|
<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 }) {
|
function ResourcesPage({ content }: { content: SiteContent }) {
|
||||||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||||||
usePageMeta(
|
usePageMeta(
|
||||||
@@ -1736,10 +1849,10 @@ function ResourcesPage({ content }: { content: SiteContent }) {
|
|||||||
<div className="site">
|
<div className="site">
|
||||||
<SiteHeader content={content} />
|
<SiteHeader content={content} />
|
||||||
<SiteSearchBar 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} />
|
<DownloadLibrarySection content={content} />
|
||||||
<CustomBlocksSection content={content} page="downloads" />
|
<CustomBlocksSection content={content} page="downloads" />
|
||||||
<TruthForLifeWidget type="resourcead" />
|
<BooksAndResourcesSection content={content} />
|
||||||
<SiteFooter content={content} />
|
<SiteFooter content={content} />
|
||||||
<AnalyticsConsentBanner content={content} />
|
<AnalyticsConsentBanner content={content} />
|
||||||
</div>
|
</div>
|
||||||
@@ -1756,6 +1869,7 @@ function AboutPage({ content }: { content: SiteContent }) {
|
|||||||
<div className="site">
|
<div className="site">
|
||||||
<SiteHeader content={content} />
|
<SiteHeader content={content} />
|
||||||
<SiteSearchBar content={content} />
|
<SiteSearchBar content={content} />
|
||||||
|
<PageBanner eyebrow="About" title="About the Show" />
|
||||||
<AboutSection content={content} />
|
<AboutSection content={content} />
|
||||||
<TestimonialsSection testimonials={content.testimonials ?? []} />
|
<TestimonialsSection testimonials={content.testimonials ?? []} />
|
||||||
<CustomBlocksSection content={content} page="about" />
|
<CustomBlocksSection content={content} page="about" />
|
||||||
@@ -1770,6 +1884,7 @@ function ContactPage({ content }: { content: SiteContent }) {
|
|||||||
<div className="site">
|
<div className="site">
|
||||||
<SiteHeader content={content} />
|
<SiteHeader content={content} />
|
||||||
<SiteSearchBar content={content} />
|
<SiteSearchBar content={content} />
|
||||||
|
<PageBanner eyebrow="Contact" title={content.contactHeading || 'Get in Touch'} />
|
||||||
<ContactSection content={content} />
|
<ContactSection content={content} />
|
||||||
<CustomBlocksSection content={content} page="contact" />
|
<CustomBlocksSection content={content} page="contact" />
|
||||||
<SiteFooter content={content} />
|
<SiteFooter content={content} />
|
||||||
@@ -2291,6 +2406,7 @@ function QuestionsPage({ content }: { content: SiteContent }) {
|
|||||||
<div className="site">
|
<div className="site">
|
||||||
<SiteHeader content={content} />
|
<SiteHeader content={content} />
|
||||||
<SiteSearchBar 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 />
|
<QASection />
|
||||||
<CustomBlocksSection content={content} page="questions" />
|
<CustomBlocksSection content={content} page="questions" />
|
||||||
<SiteFooter content={content} />
|
<SiteFooter content={content} />
|
||||||
@@ -2497,9 +2613,6 @@ export default function App() {
|
|||||||
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
|
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
|
||||||
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
|
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
|
||||||
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
|
<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="/preview" element={<PreviewPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/privacy"
|
path="/privacy"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,897 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface EmailDeliveryState {
|
|
||||||
status: string
|
|
||||||
lastEventAt: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContactSubmission {
|
|
||||||
id: string
|
|
||||||
submittedAt: string
|
|
||||||
name: string
|
|
||||||
email: string
|
|
||||||
message: string
|
|
||||||
messageType: string
|
|
||||||
subscribe: boolean
|
|
||||||
archived?: boolean
|
|
||||||
starred?: boolean
|
|
||||||
source?: string
|
|
||||||
inboundTo?: string
|
|
||||||
notes?: string
|
|
||||||
tags?: string[]
|
|
||||||
emailStatus?: {
|
|
||||||
welcome: EmailDeliveryState
|
|
||||||
adminNotification: EmailDeliveryState
|
|
||||||
adminReply: EmailDeliveryState
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReplyHistoryItem {
|
|
||||||
id: string
|
|
||||||
submissionId: string
|
|
||||||
toEmail: string
|
|
||||||
toName: string
|
|
||||||
fromEmail: string
|
|
||||||
subject: string
|
|
||||||
preview: string
|
|
||||||
sentAt: string
|
|
||||||
scheduledAt?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Contact {
|
|
||||||
key: string
|
|
||||||
email: string
|
|
||||||
name: string
|
|
||||||
source: string | undefined
|
|
||||||
subscribe: boolean
|
|
||||||
archived: boolean
|
|
||||||
firstContactAt: string // oldest submission date
|
|
||||||
latestAt: string // newest submission date
|
|
||||||
message: string
|
|
||||||
notes: string
|
|
||||||
tags: string[]
|
|
||||||
lastContactedAt: string | null
|
|
||||||
submissionCount: number
|
|
||||||
mainId: string
|
|
||||||
allIds: string[]
|
|
||||||
allSubmissions: ContactSubmission[]
|
|
||||||
bestDeliveryStatus: string | null // opened/clicked/delivered/sent/null
|
|
||||||
unreplied: boolean // has inbound messages, no reply sent
|
|
||||||
engagementScore: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConversationItem =
|
|
||||||
| { kind: 'inbound'; date: string; name: string; message: string; source?: string; id: string }
|
|
||||||
| { kind: 'outbound'; date: string; subject: string; preview: string; toEmail: string }
|
|
||||||
|
|
||||||
interface ChecklistEpisode {
|
|
||||||
id: string
|
|
||||||
series: string
|
|
||||||
episodeNumber: number | null
|
|
||||||
title: string
|
|
||||||
datePublished: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const TAG_PALETTE = [
|
|
||||||
'#1a3d2b', '#2b1a3d', '#3d1a1a', '#1a2b3d', '#3d2b1a',
|
|
||||||
'#1a3d3d', '#3d1a3d', '#2b3d1a', '#1a1a3d', '#3d3d1a',
|
|
||||||
]
|
|
||||||
|
|
||||||
function tagBg(tag: string): string {
|
|
||||||
let h = 0
|
|
||||||
for (const c of tag) h = (h * 31 + c.charCodeAt(0)) & 0x7fffffff
|
|
||||||
return TAG_PALETTE[h % TAG_PALETTE.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDate(iso: string | null | undefined) {
|
|
||||||
if (!iso) return '—'
|
|
||||||
try { return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) }
|
|
||||||
catch { return '—' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtShort(iso: string | null | undefined) {
|
|
||||||
if (!iso) return ''
|
|
||||||
try {
|
|
||||||
const d = new Date(iso)
|
|
||||||
const now = new Date()
|
|
||||||
if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
|
||||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })
|
|
||||||
} catch { return '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CSV helpers ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function csvField(v: string): string {
|
|
||||||
const s = String(v ?? '')
|
|
||||||
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportContactsCSV(contacts: Contact[]) {
|
|
||||||
const headers = ['name', 'email', 'notes', 'tags', 'source', 'subscribed', 'first_contact', 'last_contact', 'message_count']
|
|
||||||
const rows = contacts.map(c => [
|
|
||||||
c.name, c.email, c.notes,
|
|
||||||
c.tags.join(';'),
|
|
||||||
c.source ?? '',
|
|
||||||
c.subscribe ? 'yes' : 'no',
|
|
||||||
c.firstContactAt.slice(0, 10),
|
|
||||||
c.latestAt.slice(0, 10),
|
|
||||||
String(c.submissionCount),
|
|
||||||
])
|
|
||||||
const csv = [headers, ...rows].map(r => r.map(csvField).join(',')).join('\r\n')
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url; a.download = `contacts-${new Date().toISOString().slice(0, 10)}.csv`; a.click()
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 5000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCSVRow(line: string): string[] {
|
|
||||||
const result: string[] = []
|
|
||||||
let cur = '', inQ = false
|
|
||||||
for (let i = 0; i < line.length; i++) {
|
|
||||||
const ch = line[i]
|
|
||||||
if (ch === '"') {
|
|
||||||
if (inQ && line[i + 1] === '"') { cur += '"'; i++ }
|
|
||||||
else inQ = !inQ
|
|
||||||
} else if (ch === ',' && !inQ) { result.push(cur); cur = '' }
|
|
||||||
else cur += ch
|
|
||||||
}
|
|
||||||
result.push(cur)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCSV(text: string): Record<string, string>[] {
|
|
||||||
const lines = text.split(/\r?\n/).filter(l => l.trim())
|
|
||||||
if (lines.length < 2) return []
|
|
||||||
const headers = parseCSVRow(lines[0]).map(h => h.toLowerCase().trim().replace(/\s+/g, '_'))
|
|
||||||
return lines.slice(1)
|
|
||||||
.map(line => {
|
|
||||||
const vals = parseCSVRow(line)
|
|
||||||
return Object.fromEntries(headers.map((h, i) => [h, (vals[i] ?? '').trim()]))
|
|
||||||
})
|
|
||||||
.filter(r => Object.values(r).some(v => v))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Auth Shell ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function ContactsShell() {
|
|
||||||
const [authState, setAuthState] = useState<'checking' | 'needs-password' | 'needs-totp' | 'ok'>('checking')
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [totp, setTotp] = useState('')
|
|
||||||
const [authError, setAuthError] = useState('')
|
|
||||||
const [authBusy, setAuthBusy] = useState(false)
|
|
||||||
const [pendingToken, setPendingToken] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/admin-auth/status', { credentials: 'include' })
|
|
||||||
.then(r => r.json())
|
|
||||||
.then((d: { authenticated?: boolean }) => setAuthState(d.authenticated ? 'ok' : 'needs-password'))
|
|
||||||
.catch(() => setAuthState('needs-password'))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
async function handleLogin(e: React.FormEvent) {
|
|
||||||
e.preventDefault(); setAuthBusy(true); setAuthError('')
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin-auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), credentials: 'include' })
|
|
||||||
const data = await res.json() as { ok?: boolean; totpRequired?: boolean; pendingToken?: string; message?: string }
|
|
||||||
if (!res.ok) { setAuthError(data.message ?? 'Invalid password.'); setAuthBusy(false); return }
|
|
||||||
if (data.totpRequired && data.pendingToken) { setPendingToken(data.pendingToken); setAuthState('needs-totp'); setAuthBusy(false); return }
|
|
||||||
setAuthState('ok')
|
|
||||||
} catch { setAuthError('Login failed.') }
|
|
||||||
setAuthBusy(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleTotp(e: React.FormEvent) {
|
|
||||||
e.preventDefault(); setAuthBusy(true); setAuthError('')
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin-auth/totp-verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pendingToken, code: totp }), credentials: 'include' })
|
|
||||||
const data = await res.json() as { ok?: boolean; message?: string }
|
|
||||||
if (!res.ok) { setAuthError(data.message ?? 'Invalid code.'); setAuthBusy(false); return }
|
|
||||||
setAuthState('ok')
|
|
||||||
} catch { setAuthError('Verification failed.') }
|
|
||||||
setAuthBusy(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authState === 'checking') return <div className="em-auth-loading">Loading…</div>
|
|
||||||
|
|
||||||
if (authState === 'needs-password') return (
|
|
||||||
<div className="em-auth-wrap">
|
|
||||||
<form className="em-auth-form" onSubmit={handleLogin}>
|
|
||||||
<h1 className="em-auth-title">Contacts</h1>
|
|
||||||
<label className="em-auth-label">Admin password<input type="password" className="em-auth-input" value={password} onChange={e => setPassword(e.target.value)} autoFocus /></label>
|
|
||||||
{authError && <p className="em-auth-error">{authError}</p>}
|
|
||||||
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>{authBusy ? 'Signing in…' : 'Sign in'}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (authState === 'needs-totp') return (
|
|
||||||
<div className="em-auth-wrap">
|
|
||||||
<form className="em-auth-form" onSubmit={handleTotp}>
|
|
||||||
<h1 className="em-auth-title">Two-factor code</h1>
|
|
||||||
<label className="em-auth-label">Authenticator code<input type="text" className="em-auth-input" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={totp} onChange={e => setTotp(e.target.value)} autoFocus /></label>
|
|
||||||
{authError && <p className="em-auth-error">{authError}</p>}
|
|
||||||
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>{authBusy ? 'Verifying…' : 'Verify'}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return <ContactsClient />
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Contacts Client ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ContactsClient() {
|
|
||||||
const [submissions, setSubmissions] = useState<ContactSubmission[]>([])
|
|
||||||
const [replyHistory, setReplyHistory] = useState<ReplyHistoryItem[]>([])
|
|
||||||
const [checklistEpisodes, setChecklistEpisodes] = useState<ChecklistEpisode[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [tagFilter, setTagFilter] = useState('')
|
|
||||||
const [showArchived, setShowArchived] = useState(false)
|
|
||||||
|
|
||||||
// Edit state
|
|
||||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
|
||||||
const [editName, setEditName] = useState('')
|
|
||||||
const [editNotes, setEditNotes] = useState('')
|
|
||||||
const [editTags, setEditTags] = useState<string[]>([])
|
|
||||||
const [editTagInput, setEditTagInput] = useState('')
|
|
||||||
const [editSaving, setEditSaving] = useState(false)
|
|
||||||
|
|
||||||
// Merge state
|
|
||||||
const [mergePickerKey, setMergePickerKey] = useState<string | null>(null)
|
|
||||||
const [mergeSearch, setMergeSearch] = useState('')
|
|
||||||
const [mergeBusy, setMergeBusy] = useState(false)
|
|
||||||
const [mergeMsg, setMergeMsg] = useState('')
|
|
||||||
// Drip trigger
|
|
||||||
const [dripBusyKey, setDripBusyKey] = useState<string | null>(null)
|
|
||||||
const [dripMsgKey, setDripMsgKey] = useState<string | null>(null)
|
|
||||||
const [dripMsgText, setDripMsgText] = useState('')
|
|
||||||
|
|
||||||
// History state
|
|
||||||
const [expandedHistoryKey, setExpandedHistoryKey] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Add contact
|
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
|
||||||
const [addName, setAddName] = useState('')
|
|
||||||
const [addEmail, setAddEmail] = useState('')
|
|
||||||
const [addNotes, setAddNotes] = useState('')
|
|
||||||
const [addTags, setAddTags] = useState('')
|
|
||||||
const [addBusy, setAddBusy] = useState(false)
|
|
||||||
const [addError, setAddError] = useState('')
|
|
||||||
|
|
||||||
// CSV import
|
|
||||||
const [importBusy, setImportBusy] = useState(false)
|
|
||||||
const [importMsg, setImportMsg] = useState('')
|
|
||||||
const [importPreview, setImportPreview] = useState<{ rows: Record<string, string>[]; filename: string } | null>(null)
|
|
||||||
const importFileRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
// Flash
|
|
||||||
const [flashMsg, setFlashMsg] = useState('')
|
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const [subRes, histRes, clRes] = await Promise.all([
|
|
||||||
fetch('/api/admin-contact-submissions', { credentials: 'include' }),
|
|
||||||
fetch('/api/admin-contact-reply-history', { credentials: 'include' }),
|
|
||||||
fetch('/api/admin-podcast-checklist', { credentials: 'include' }),
|
|
||||||
])
|
|
||||||
if (subRes.ok) {
|
|
||||||
const d = await subRes.json() as { submissions: ContactSubmission[] }
|
|
||||||
setSubmissions(d.submissions ?? [])
|
|
||||||
}
|
|
||||||
if (histRes.ok) {
|
|
||||||
const d = await histRes.json() as { items: ReplyHistoryItem[] }
|
|
||||||
setReplyHistory(d.items ?? [])
|
|
||||||
}
|
|
||||||
if (clRes.ok) {
|
|
||||||
const d = await clRes.json() as { checklist?: { episodes?: ChecklistEpisode[] } }
|
|
||||||
setChecklistEpisodes(d.checklist?.episodes ?? [])
|
|
||||||
}
|
|
||||||
} catch { /* silent */ }
|
|
||||||
setLoading(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => { reload() }, [reload])
|
|
||||||
|
|
||||||
// ── Derived contacts ──
|
|
||||||
|
|
||||||
const contacts: Contact[] = useMemo(() => {
|
|
||||||
// Group by email (lowercased), falling back to id
|
|
||||||
const grouped = new Map<string, ContactSubmission[]>()
|
|
||||||
for (const s of submissions) {
|
|
||||||
const key = s.email?.trim().toLowerCase() || s.id
|
|
||||||
const arr = grouped.get(key) ?? []
|
|
||||||
arr.push(s)
|
|
||||||
grouped.set(key, arr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build last-contacted index from reply history
|
|
||||||
const lastContactedMap = new Map<string, string>()
|
|
||||||
for (const item of replyHistory) {
|
|
||||||
const email = item.toEmail?.trim().toLowerCase()
|
|
||||||
if (!email) continue
|
|
||||||
const existing = lastContactedMap.get(email)
|
|
||||||
if (!existing || item.sentAt > existing) lastContactedMap.set(email, item.sentAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(grouped.values())
|
|
||||||
.map(entries => {
|
|
||||||
const sorted = [...entries].sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
|
||||||
const latest = sorted[0]
|
|
||||||
const oldest = sorted[sorted.length - 1]
|
|
||||||
const emailKey = latest.email?.trim().toLowerCase() || latest.id
|
|
||||||
// Tags: prefer entry that has tags, falling back to mainId submission
|
|
||||||
const withTags = sorted.find(s => s.tags && s.tags.length > 0)
|
|
||||||
// Best delivery status: clicked > opened > delivered > sent
|
|
||||||
const STATUS_RANK: Record<string, number> = { clicked: 4, opened: 3, delivered: 2, sent: 1 }
|
|
||||||
let bestDeliveryStatus: string | null = null
|
|
||||||
let bestRank = -1
|
|
||||||
for (const s of sorted) {
|
|
||||||
const st = s.emailStatus?.adminReply?.status
|
|
||||||
if (st && STATUS_RANK[st] !== undefined && STATUS_RANK[st] > bestRank) {
|
|
||||||
bestRank = STATUS_RANK[st]
|
|
||||||
bestDeliveryStatus = st
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const repliesForContact = replyHistory.filter(r => r.toEmail?.trim().toLowerCase() === emailKey)
|
|
||||||
const unreplied = sorted.some(s => !s.archived) && repliesForContact.length === 0
|
|
||||||
|
|
||||||
const engagementScore =
|
|
||||||
repliesForContact.length * 3 +
|
|
||||||
sorted.filter(s => s.emailStatus?.adminReply?.status === 'clicked').length * 2 +
|
|
||||||
sorted.filter(s => s.emailStatus?.adminReply?.status === 'opened').length * 1 +
|
|
||||||
(sorted.some(s => s.source === 'download') ? 2 : 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: emailKey,
|
|
||||||
email: latest.email ?? '',
|
|
||||||
name: latest.name ?? '',
|
|
||||||
source: latest.source,
|
|
||||||
subscribe: sorted.some(s => s.subscribe),
|
|
||||||
archived: sorted.every(s => s.archived === true),
|
|
||||||
firstContactAt: oldest.submittedAt,
|
|
||||||
latestAt: latest.submittedAt,
|
|
||||||
message: latest.message || sorted.find(s => s.message)?.message || '',
|
|
||||||
notes: sorted.find(s => s.notes)?.notes ?? '',
|
|
||||||
tags: withTags?.tags ?? latest.tags ?? [],
|
|
||||||
lastContactedAt: lastContactedMap.get(emailKey) ?? null,
|
|
||||||
submissionCount: sorted.length,
|
|
||||||
mainId: latest.id,
|
|
||||||
allIds: sorted.map(s => s.id),
|
|
||||||
allSubmissions: sorted,
|
|
||||||
bestDeliveryStatus,
|
|
||||||
unreplied,
|
|
||||||
engagementScore,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.sort((a, b) => new Date(b.latestAt).getTime() - new Date(a.latestAt).getTime())
|
|
||||||
}, [submissions, replyHistory])
|
|
||||||
|
|
||||||
// ── All tags (for filter dropdown + autocomplete) ──
|
|
||||||
|
|
||||||
const allTags = useMemo(() => {
|
|
||||||
const set = new Set<string>()
|
|
||||||
for (const c of contacts) for (const t of c.tags) set.add(t)
|
|
||||||
return [...set].sort()
|
|
||||||
}, [contacts])
|
|
||||||
|
|
||||||
// ── Filtered list ──
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
return contacts.filter(c => {
|
|
||||||
if (!showArchived && c.archived) return false
|
|
||||||
if (tagFilter && !c.tags.includes(tagFilter)) return false
|
|
||||||
if (!search.trim()) return true
|
|
||||||
const q = search.toLowerCase()
|
|
||||||
return (
|
|
||||||
c.name.toLowerCase().includes(q) ||
|
|
||||||
c.email.toLowerCase().includes(q) ||
|
|
||||||
c.message.toLowerCase().includes(q) ||
|
|
||||||
c.notes.toLowerCase().includes(q) ||
|
|
||||||
c.tags.some(t => t.toLowerCase().includes(q))
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}, [contacts, showArchived, tagFilter, search])
|
|
||||||
|
|
||||||
// ── Edit helpers ──
|
|
||||||
|
|
||||||
function startEdit(c: Contact) {
|
|
||||||
setEditingKey(c.key)
|
|
||||||
setEditName(c.name)
|
|
||||||
setEditNotes(c.notes)
|
|
||||||
setEditTags([...c.tags])
|
|
||||||
setEditTagInput('')
|
|
||||||
setMergePickerKey(null)
|
|
||||||
setMergeMsg('')
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEdit() {
|
|
||||||
setEditingKey(null)
|
|
||||||
setMergePickerKey(null)
|
|
||||||
setMergeMsg('')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEdit(c: Contact) {
|
|
||||||
setEditSaving(true)
|
|
||||||
try {
|
|
||||||
await fetch(`/api/admin-contact-submissions/${encodeURIComponent(c.mainId)}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ name: editName, notes: editNotes, tags: editTags }),
|
|
||||||
})
|
|
||||||
setSubmissions(prev => prev.map(s =>
|
|
||||||
s.id === c.mainId
|
|
||||||
? { ...s, name: editName, notes: editNotes, tags: editTags }
|
|
||||||
: (s.email?.trim().toLowerCase() === c.key ? { ...s, name: editName } : s)
|
|
||||||
))
|
|
||||||
setEditingKey(null)
|
|
||||||
} catch { /* silent */ }
|
|
||||||
setEditSaving(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function addEditTag(tag: string) {
|
|
||||||
const t = tag.trim().slice(0, 50)
|
|
||||||
if (!t || editTags.includes(t)) return
|
|
||||||
setEditTags(prev => [...prev, t])
|
|
||||||
setEditTagInput('')
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeEditTag(tag: string) {
|
|
||||||
setEditTags(prev => prev.filter(t => t !== tag))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Delete ──
|
|
||||||
|
|
||||||
async function deleteContact(c: Contact) {
|
|
||||||
const label = c.name || c.email || 'this contact'
|
|
||||||
const plural = c.submissionCount > 1 ? `all ${c.submissionCount} submissions` : 'submission'
|
|
||||||
if (!confirm(`Delete ${plural} from ${label}?`)) return
|
|
||||||
await Promise.all(
|
|
||||||
c.allIds.map(id =>
|
|
||||||
fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, { method: 'DELETE', credentials: 'include' })
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Merge ──
|
|
||||||
|
|
||||||
async function triggerDrip(c: Contact) {
|
|
||||||
setDripBusyKey(c.key); setDripMsgKey(c.key); setDripMsgText('')
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin-contacts/trigger-drip', {
|
|
||||||
method: 'POST', credentials: 'include',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email: c.email }),
|
|
||||||
})
|
|
||||||
const d = await res.json() as { ok?: boolean; note?: string; message?: string }
|
|
||||||
setDripMsgText(res.ok ? (d.note ?? 'Drip triggered.') : (d.message ?? 'Failed.'))
|
|
||||||
} catch { setDripMsgText('Network error.') }
|
|
||||||
setDripBusyKey(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doMerge(keepContact: Contact, mergeContact: Contact) {
|
|
||||||
if (!confirm(`Merge "${mergeContact.name || mergeContact.email}" into "${keepContact.name || keepContact.email}"? All messages from ${mergeContact.email} will be reassigned to ${keepContact.email}.`)) return
|
|
||||||
setMergeBusy(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin-contacts/merge', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ keepEmail: keepContact.email, mergeEmail: mergeContact.email }),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
setMergeMsg('')
|
|
||||||
setMergePickerKey(null)
|
|
||||||
setEditingKey(null)
|
|
||||||
flash(`Merged ${mergeContact.email} into ${keepContact.email}.`)
|
|
||||||
await reload()
|
|
||||||
} else {
|
|
||||||
const d = await res.json() as { message?: string }
|
|
||||||
setMergeMsg(d.message ?? 'Merge failed.')
|
|
||||||
}
|
|
||||||
} catch { setMergeMsg('Network error.') }
|
|
||||||
setMergeBusy(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Add contact ──
|
|
||||||
|
|
||||||
async function handleAdd(e: React.FormEvent) {
|
|
||||||
e.preventDefault(); setAddBusy(true); setAddError('')
|
|
||||||
try {
|
|
||||||
const tags = addTags.split(',').map(t => t.trim()).filter(Boolean)
|
|
||||||
const res = await fetch('/api/admin-contact-submissions/add', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ name: addName, email: addEmail, notes: addNotes, tags }),
|
|
||||||
})
|
|
||||||
const d = await res.json() as { ok?: boolean; message?: string }
|
|
||||||
if (!res.ok) { setAddError(d.message ?? 'Failed to add.'); setAddBusy(false); return }
|
|
||||||
setAddOpen(false); setAddName(''); setAddEmail(''); setAddNotes(''); setAddTags('')
|
|
||||||
await reload()
|
|
||||||
} catch { setAddError('Network error.') }
|
|
||||||
setAddBusy(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CSV import ──
|
|
||||||
|
|
||||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = evt => {
|
|
||||||
const text = evt.target?.result as string
|
|
||||||
const rows = parseCSV(text)
|
|
||||||
setImportPreview({ rows, filename: file.name })
|
|
||||||
}
|
|
||||||
reader.readAsText(file)
|
|
||||||
if (importFileRef.current) importFileRef.current.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doImport() {
|
|
||||||
if (!importPreview) return
|
|
||||||
setImportBusy(true); setImportMsg('')
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin-contacts/import', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ rows: importPreview.rows }),
|
|
||||||
})
|
|
||||||
const d = await res.json() as { ok?: boolean; created?: number; skipped?: number; message?: string }
|
|
||||||
if (!res.ok) { setImportMsg(d.message ?? 'Import failed.'); setImportBusy(false); return }
|
|
||||||
setImportMsg(`Imported ${d.created} contact${d.created !== 1 ? 's' : ''}${d.skipped ? `, skipped ${d.skipped}` : ''}.`)
|
|
||||||
setImportPreview(null)
|
|
||||||
await reload()
|
|
||||||
} catch { setImportMsg('Network error.') }
|
|
||||||
setImportBusy(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Flash ──
|
|
||||||
|
|
||||||
function flash(msg: string) {
|
|
||||||
setFlashMsg(msg)
|
|
||||||
setTimeout(() => setFlashMsg(''), 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Conversation history builder ──
|
|
||||||
|
|
||||||
function buildHistory(c: Contact): ConversationItem[] {
|
|
||||||
const inbound: ConversationItem[] = c.allSubmissions.map(s => ({
|
|
||||||
kind: 'inbound',
|
|
||||||
date: s.submittedAt,
|
|
||||||
name: s.name,
|
|
||||||
message: s.source === 'inbound-email'
|
|
||||||
? s.message.replace(/^Subject:\s*.+\n+/m, '').trim().slice(0, 400)
|
|
||||||
: s.message.slice(0, 400),
|
|
||||||
source: s.source,
|
|
||||||
id: s.id,
|
|
||||||
}))
|
|
||||||
const outbound: ConversationItem[] = replyHistory
|
|
||||||
.filter(r => r.toEmail?.trim().toLowerCase() === c.key)
|
|
||||||
.map(r => ({
|
|
||||||
kind: 'outbound',
|
|
||||||
date: r.scheduledAt ?? r.sentAt,
|
|
||||||
subject: r.subject,
|
|
||||||
preview: r.preview,
|
|
||||||
toEmail: r.toEmail,
|
|
||||||
}))
|
|
||||||
return [...inbound, ...outbound].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Source badge ──
|
|
||||||
|
|
||||||
function sourceBadge(source: string | undefined) {
|
|
||||||
if (source === 'manual') return <span className="ct-badge ct-badge--manual">manual</span>
|
|
||||||
if (source === 'inbound-email') return <span className="ct-badge ct-badge--email">email</span>
|
|
||||||
if (source === 'download') return <span className="ct-badge ct-badge--download">download</span>
|
|
||||||
return <span className="ct-badge ct-badge--form">form</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render ──
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ct-app">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="ct-header">
|
|
||||||
<div className="ct-header-brand">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" />
|
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
||||||
</svg>
|
|
||||||
Contacts
|
|
||||||
<span className="ct-header-count">{contacts.length}</span>
|
|
||||||
</div>
|
|
||||||
<div className="ct-header-actions">
|
|
||||||
<button type="button" className="em-btn em-btn--secondary em-btn--sm" onClick={() => { setAddOpen(o => !o); setAddError('') }}>
|
|
||||||
{addOpen ? 'Cancel' : '+ Add Contact'}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => exportContactsCSV(filtered)} title="Export visible contacts to CSV">
|
|
||||||
↓ Export CSV
|
|
||||||
</button>
|
|
||||||
<label className="em-btn em-btn--ghost em-btn--sm" style={{ cursor: 'pointer' }}>
|
|
||||||
↑ Import CSV
|
|
||||||
<input ref={importFileRef} type="file" accept=".csv,text/csv" style={{ display: 'none' }} onChange={handleFileChange} />
|
|
||||||
</label>
|
|
||||||
<Link to="/email" className="em-btn em-btn--ghost em-btn--sm">✉ Email</Link>
|
|
||||||
<Link to="/calendar" className="em-btn em-btn--ghost em-btn--sm">Calendar</Link>
|
|
||||||
<Link to="/admin" className="em-btn em-btn--ghost em-btn--sm">← Admin</Link>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Import preview */}
|
|
||||||
{importPreview && (
|
|
||||||
<div className="ct-import-banner">
|
|
||||||
<div className="ct-import-info">
|
|
||||||
<strong>{importPreview.filename}</strong> — {importPreview.rows.length} row{importPreview.rows.length !== 1 ? 's' : ''} found
|
|
||||||
{importPreview.rows.length > 0 && (
|
|
||||||
<span className="ct-import-cols"> · columns: {Object.keys(importPreview.rows[0]).join(', ')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="ct-import-actions">
|
|
||||||
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={doImport} disabled={importBusy || importPreview.rows.length === 0}>
|
|
||||||
{importBusy ? 'Importing…' : `Import ${importPreview.rows.length} contacts`}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setImportPreview(null); setImportMsg('') }}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
{importMsg && <p className="ct-import-msg">{importMsg}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{importMsg && !importPreview && <div className="ct-flash">{importMsg}</div>}
|
|
||||||
{flashMsg && <div className="ct-flash">{flashMsg}</div>}
|
|
||||||
|
|
||||||
{/* Add contact form */}
|
|
||||||
{addOpen && (
|
|
||||||
<div className="ct-add-banner">
|
|
||||||
<form className="ct-add-form" onSubmit={handleAdd}>
|
|
||||||
<h3 className="ct-add-title">Add Contact</h3>
|
|
||||||
<div className="ct-add-row">
|
|
||||||
<label className="ct-add-label">Name<input className="ct-input" type="text" placeholder="Full name" value={addName} onChange={e => setAddName(e.target.value)} autoFocus /></label>
|
|
||||||
<label className="ct-add-label">Email<input className="ct-input" type="email" placeholder="email@example.com" value={addEmail} onChange={e => setAddEmail(e.target.value)} /></label>
|
|
||||||
<label className="ct-add-label">Tags (comma-separated)<input className="ct-input" type="text" placeholder="listener, partner" value={addTags} onChange={e => setAddTags(e.target.value)} /></label>
|
|
||||||
</div>
|
|
||||||
<label className="ct-add-label ct-add-label--full">Notes<textarea className="ct-input ct-notes-input" placeholder="Notes (optional)" value={addNotes} onChange={e => setAddNotes(e.target.value)} rows={2} /></label>
|
|
||||||
{addError && <p className="ct-error">{addError}</p>}
|
|
||||||
<div className="ct-add-actions">
|
|
||||||
<button type="submit" className="em-btn em-btn--primary em-btn--sm" disabled={addBusy}>{addBusy ? 'Adding…' : 'Add Contact'}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="ct-toolbar">
|
|
||||||
<input type="search" className="ct-search" placeholder="Search name, email, notes, tags…" value={search} onChange={e => setSearch(e.target.value)} />
|
|
||||||
<select className="ct-tag-filter" value={tagFilter} onChange={e => setTagFilter(e.target.value)}>
|
|
||||||
<option value="">All tags</option>
|
|
||||||
{allTags.map(t => <option key={t} value={t}>{t}</option>)}
|
|
||||||
</select>
|
|
||||||
<label className="ct-archived-toggle">
|
|
||||||
<input type="checkbox" checked={showArchived} onChange={e => setShowArchived(e.target.checked)} />
|
|
||||||
Show archived
|
|
||||||
</label>
|
|
||||||
<span className="ct-count-label">{filtered.length} contact{filtered.length !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact list */}
|
|
||||||
<div className="ct-list">
|
|
||||||
{loading && <p className="ct-empty">Loading contacts…</p>}
|
|
||||||
{!loading && filtered.length === 0 && (
|
|
||||||
<p className="ct-empty">{search || tagFilter ? 'No contacts match.' : 'No contacts yet.'}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filtered.map(c => {
|
|
||||||
const isEditing = editingKey === c.key
|
|
||||||
const historyOpen = expandedHistoryKey === c.key
|
|
||||||
const history = historyOpen ? buildHistory(c) : []
|
|
||||||
const isMergeTarget = mergePickerKey === c.key
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={c.mainId} className={`ct-card${c.archived ? ' ct-card--archived' : ''}`}>
|
|
||||||
<div className="ct-avatar" aria-hidden="true">
|
|
||||||
{(c.name || c.email || '?').charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ct-card-body">
|
|
||||||
{isEditing ? (
|
|
||||||
/* ── Edit mode ── */
|
|
||||||
<div className="ct-edit-form">
|
|
||||||
<div className="ct-edit-row">
|
|
||||||
<label className="ct-edit-label">Name<input className="ct-input" type="text" value={editName} onChange={e => setEditName(e.target.value)} autoFocus /></label>
|
|
||||||
</div>
|
|
||||||
<label className="ct-edit-label">
|
|
||||||
Tags
|
|
||||||
<div className="ct-tag-editor">
|
|
||||||
{editTags.map(t => (
|
|
||||||
<span key={t} className="ct-tag" style={{ background: tagBg(t) }}>
|
|
||||||
{t}
|
|
||||||
<button type="button" className="ct-tag-remove" onClick={() => removeEditTag(t)} aria-label={`Remove ${t}`}>×</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<input
|
|
||||||
className="ct-tag-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Add tag…"
|
|
||||||
value={editTagInput}
|
|
||||||
list="ct-tag-suggestions"
|
|
||||||
onChange={e => setEditTagInput(e.target.value)}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addEditTag(editTagInput) }
|
|
||||||
else if (e.key === 'Backspace' && !editTagInput && editTags.length) removeEditTag(editTags[editTags.length - 1])
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<datalist id="ct-tag-suggestions">
|
|
||||||
{allTags.filter(t => !editTags.includes(t)).map(t => <option key={t} value={t} />)}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label className="ct-edit-label">Notes<textarea className="ct-input ct-notes-input" rows={3} value={editNotes} onChange={e => setEditNotes(e.target.value)} placeholder="Add a note…" /></label>
|
|
||||||
|
|
||||||
{/* Merge picker */}
|
|
||||||
{isMergeTarget && (
|
|
||||||
<div className="ct-merge-picker">
|
|
||||||
<p className="ct-merge-label">Merge another contact into <strong>{c.name || c.email}</strong>:</p>
|
|
||||||
<input className="ct-input ct-merge-search" type="search" placeholder="Search contacts to merge…" value={mergeSearch} autoFocus onChange={e => setMergeSearch(e.target.value)} />
|
|
||||||
<div className="ct-merge-list">
|
|
||||||
{contacts
|
|
||||||
.filter(other =>
|
|
||||||
other.key !== c.key &&
|
|
||||||
(!mergeSearch || other.name.toLowerCase().includes(mergeSearch.toLowerCase()) || other.email.toLowerCase().includes(mergeSearch.toLowerCase()))
|
|
||||||
)
|
|
||||||
.slice(0, 20)
|
|
||||||
.map(other => (
|
|
||||||
<button key={other.key} type="button" className="ct-merge-option" onClick={() => doMerge(c, other)} disabled={mergeBusy}>
|
|
||||||
<span className="ct-merge-name">{other.name || <em>No name</em>}</span>
|
|
||||||
<span className="ct-merge-email">{other.email}</span>
|
|
||||||
<span className="ct-merge-count">{other.submissionCount} msg{other.submissionCount !== 1 ? 's' : ''}</span>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
{mergeMsg && <p className="ct-error">{mergeMsg}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="ct-edit-actions">
|
|
||||||
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={() => saveEdit(c)} disabled={editSaving}>{editSaving ? 'Saving…' : 'Save'}</button>
|
|
||||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setMergePickerKey(prev => prev === c.key ? null : c.key); setMergeSearch(''); setMergeMsg('') }}>
|
|
||||||
{isMergeTarget ? 'Cancel merge' : 'Merge with…'}
|
|
||||||
</button>
|
|
||||||
{c.email && (
|
|
||||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" title="Sync to Resend audience and trigger drip automation" onClick={() => triggerDrip(c)} disabled={dripBusyKey === c.key}>
|
|
||||||
{dripBusyKey === c.key ? 'Triggering…' : '▶ Drip'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={cancelEdit}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
{dripMsgKey === c.key && dripMsgText && (
|
|
||||||
<p className="ct-drip-msg">{dripMsgText}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* ── Display mode ── */
|
|
||||||
<>
|
|
||||||
<div className="ct-card-top">
|
|
||||||
<span className="ct-card-name">{c.name || <em>No name</em>}</span>
|
|
||||||
{c.email && <a className="ct-card-email" href={`mailto:${c.email}`}>{c.email}</a>}
|
|
||||||
{sourceBadge(c.source)}
|
|
||||||
{c.subscribe && <span className="ct-badge ct-badge--sub">subscriber</span>}
|
|
||||||
{c.unreplied && <span className="ct-badge ct-badge--unreplied" title="No reply sent yet">needs reply</span>}
|
|
||||||
{c.bestDeliveryStatus && (
|
|
||||||
<span className={`ct-delivery-pill ct-delivery-pill--${c.bestDeliveryStatus}`}>
|
|
||||||
{c.bestDeliveryStatus === 'clicked' ? '🔗 clicked' : c.bestDeliveryStatus === 'opened' ? '👁 opened' : c.bestDeliveryStatus === 'delivered' ? '✓ delivered' : '→ sent'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{c.engagementScore >= 3 && <span className="ct-engage-badge" title={`Engagement score: ${c.engagementScore}`}>{'★'.repeat(Math.min(3, Math.floor(c.engagementScore / 3)))}</span>}
|
|
||||||
{c.submissionCount > 1 && <span className="ct-count-badge">{c.submissionCount}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags row */}
|
|
||||||
{c.tags.length > 0 && (
|
|
||||||
<div className="ct-tags-row">
|
|
||||||
{c.tags.map(t => (
|
|
||||||
<span key={t} className="ct-tag" style={{ background: tagBg(t) }}>{t}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="ct-card-meta-row">
|
|
||||||
<span className="ct-card-date">
|
|
||||||
First: {fmtDate(c.firstContactAt)}
|
|
||||||
{c.lastContactedAt && <> · <span className="ct-last-contacted">Last replied: {fmtShort(c.lastContactedAt)}</span></>}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{c.notes && <p className="ct-card-notes">{c.notes}</p>}
|
|
||||||
|
|
||||||
{c.message && (
|
|
||||||
<p className="ct-card-preview">
|
|
||||||
{c.message.length > 100 ? c.message.slice(0, 100) + '…' : c.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{!isEditing && (
|
|
||||||
<div className="ct-card-actions">
|
|
||||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => startEdit(c)}>Edit</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`em-btn em-btn--sm ${historyOpen ? 'em-btn--secondary' : 'em-btn--ghost'}`}
|
|
||||||
onClick={() => setExpandedHistoryKey(prev => prev === c.key ? null : c.key)}
|
|
||||||
>
|
|
||||||
History{c.submissionCount > 1 || replyHistory.some(r => r.toEmail?.trim().toLowerCase() === c.key) ? ` (${c.submissionCount})` : ''}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="em-btn em-btn--danger em-btn--sm" onClick={() => deleteContact(c)}>Delete</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Conversation history panel */}
|
|
||||||
{historyOpen && !isEditing && (() => {
|
|
||||||
const relatedEps = checklistEpisodes.filter(ep => {
|
|
||||||
if (!ep.datePublished) return false
|
|
||||||
const epMs = new Date(ep.datePublished + 'T12:00:00').getTime()
|
|
||||||
const refMs = new Date(c.latestAt).getTime()
|
|
||||||
return Math.abs(epMs - refMs) <= 30 * 24 * 60 * 60 * 1000
|
|
||||||
})
|
|
||||||
return (
|
|
||||||
<div className="ct-history-panel">
|
|
||||||
{history.length === 0 && relatedEps.length === 0 && <p className="ct-history-empty">No conversation history.</p>}
|
|
||||||
{history.map((item, i) => (
|
|
||||||
item.kind === 'inbound' ? (
|
|
||||||
<div key={item.id || i} className="ct-history-item ct-history-item--in">
|
|
||||||
<div className="ct-history-meta">
|
|
||||||
<span className="ct-history-who">{item.name}</span>
|
|
||||||
<span className="ct-history-date">{fmtShort(item.date)}</span>
|
|
||||||
</div>
|
|
||||||
<p className="ct-history-body">{item.message || '(no message body)'}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div key={i} className="ct-history-item ct-history-item--out">
|
|
||||||
<div className="ct-history-meta">
|
|
||||||
<span className="ct-history-who">You → {item.toEmail}</span>
|
|
||||||
<span className="ct-history-date">{fmtShort(item.date)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="ct-history-subject">{item.subject}</div>
|
|
||||||
<p className="ct-history-body">{item.preview}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
))}
|
|
||||||
{relatedEps.length > 0 && (
|
|
||||||
<div className="ct-history-ep-section">
|
|
||||||
<p className="ct-history-ep-label">📅 Episodes near this contact</p>
|
|
||||||
{relatedEps.map(ep => {
|
|
||||||
const parts = [ep.series, ep.episodeNumber != null ? `Ep. ${ep.episodeNumber}` : null, ep.title].filter(Boolean)
|
|
||||||
return (
|
|
||||||
<div key={ep.id} className="ct-history-item ct-history-item--ep">
|
|
||||||
<div className="ct-history-meta">
|
|
||||||
<span className="ct-history-who">{parts.join(' – ') || 'Untitled episode'}</span>
|
|
||||||
<span className="ct-history-date">{ep.datePublished}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-1557
File diff suppressed because it is too large
Load Diff
+28
-1
@@ -1506,6 +1506,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
const [bannerDismissed, setBannerDismissed] = useState(() => localStorage.getItem('study-banner-dismissed-v1') === '1')
|
const [bannerDismissed, setBannerDismissed] = useState(() => localStorage.getItem('study-banner-dismissed-v1') === '1')
|
||||||
const [autoSaveMsg, setAutoSaveMsg] = useState('')
|
const [autoSaveMsg, setAutoSaveMsg] = useState('')
|
||||||
const [_checkpointReflection, _setCheckpointReflection] = 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 savedNoteCount = Object.values(noteMap).filter(v => v?.trim()).length
|
||||||
const lessonAudioEmbedUrl = useMemo(() => {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!auth.checked || !auth.authenticated || !study || !isEnrolled) return
|
if (!auth.checked || !auth.authenticated || !study || !isEnrolled) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -1908,7 +1925,17 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
>✎</button>
|
>✎</button>
|
||||||
)}
|
)}
|
||||||
<h2>Scripture Text</h2>
|
<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' && (
|
{isEnrolled && openAnchor === 'scripture' && (
|
||||||
<NoteCard
|
<NoteCard
|
||||||
anchorLabel="Scripture Text"
|
anchorLabel="Scripture Text"
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function AnalyticsPanel({
|
|||||||
onDeployHook,
|
onDeployHook,
|
||||||
onRefreshStatus,
|
onRefreshStatus,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const [panelTab, setPanelTab] = useState<'traffic' | 'engagement' | 'system'>('traffic')
|
||||||
const [chartTab, setChartTab] = useState<'overview' | 'breakdown' | 'geographic' | 'referrers'>('overview')
|
const [chartTab, setChartTab] = useState<'overview' | 'breakdown' | 'geographic' | 'referrers'>('overview')
|
||||||
const [expandedVisitor, setExpandedVisitor] = useState<string | null>(null)
|
const [expandedVisitor, setExpandedVisitor] = useState<string | null>(null)
|
||||||
const [fullBackupFile, setFullBackupFile] = useState<File | 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>
|
<p>Real-time insights into site traffic, bot detection, visitor behavior, and data management.</p>
|
||||||
</div>
|
</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 */}
|
{/* Time-range tabs */}
|
||||||
{onRangeChange && (
|
{onRangeChange && (
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||||
@@ -523,6 +532,9 @@ export function AnalyticsPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{panelTab === 'engagement' && (<>
|
||||||
{/* Episode Plays */}
|
{/* Episode Plays */}
|
||||||
{stats.episodePlays && stats.episodePlays.length > 0 && (
|
{stats.episodePlays && stats.episodePlays.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -790,6 +802,9 @@ export function AnalyticsPanel({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{panelTab === 'system' && (<>
|
||||||
{/* Deployment & Cache Status */}
|
{/* Deployment & Cache Status */}
|
||||||
<div className="admin-stats-head admin-stats-head--visitors">
|
<div className="admin-stats-head admin-stats-head--visitors">
|
||||||
<h2>Deployment & Cache Status</h2>
|
<h2>Deployment & Cache Status</h2>
|
||||||
@@ -872,6 +887,7 @@ export function AnalyticsPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{maintenanceMsg && <p className="admin-stats-note">{maintenanceMsg}</p>}
|
{maintenanceMsg && <p className="admin-stats-note">{maintenanceMsg}</p>}
|
||||||
|
</>)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -783,10 +783,6 @@ export default function QASection() {
|
|||||||
return (
|
return (
|
||||||
<section id="qa" className="section-qa" aria-label="Questions and answers">
|
<section id="qa" className="section-qa" aria-label="Questions and answers">
|
||||||
<div className="section-inner">
|
<div className="section-inner">
|
||||||
<h2 className="section-heading">
|
|
||||||
<span className="ornament">✦</span> Questions & Answers <span className="ornament">✦</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="qa-submit-cta">
|
<div className="qa-submit-cta">
|
||||||
<p className="qa-submit-cta-text">Have a question about Scripture or the podcast?</p>
|
<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>
|
<Link to="/contact" className="btn-primary">Submit a Question →</Link>
|
||||||
|
|||||||
+4
-1
@@ -9,6 +9,7 @@ export interface StudyProgram {
|
|||||||
slug: string
|
slug: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
|
bibleBook?: string
|
||||||
homepageEyebrow?: string
|
homepageEyebrow?: string
|
||||||
showOnHomepage?: boolean
|
showOnHomepage?: boolean
|
||||||
showNewTag?: boolean
|
showNewTag?: boolean
|
||||||
@@ -25,7 +26,7 @@ export interface CustomLink {
|
|||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
url: string
|
url: string
|
||||||
placement: 'platforms' | 'footer' | 'resources' | 'otherSites' | 'externalSites'
|
placement: 'platforms' | 'footer' | 'resources' | 'otherSites' | 'externalSites' | 'books' | 'recommended'
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
description?: string
|
description?: string
|
||||||
amazonUrl?: string
|
amazonUrl?: string
|
||||||
@@ -38,6 +39,8 @@ export interface CustomBlock {
|
|||||||
heading: string
|
heading: string
|
||||||
body: string
|
body: string
|
||||||
page?: 'homepage' | 'start-here' | 'episodes' | 'downloads' | 'about' | 'contact' | 'questions'
|
page?: 'homepage' | 'start-here' | 'episodes' | 'downloads' | 'about' | 'contact' | 'questions'
|
||||||
|
linkUrl?: string
|
||||||
|
linkLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WhereToNextCard {
|
export interface WhereToNextCard {
|
||||||
|
|||||||
Reference in New Issue
Block a user