diff --git a/package-lock.json b/package-lock.json index 0e6eafa..f071c58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "docx": "^9.6.1", "express": "^5.2.1", "express-rate-limit": "^8.4.1", + "fuse.js": "^7.4.2", + "mammoth": "^1.12.0", "otplib": "^13.4.0", "prop-types": "^15.8.1", "qrcode": "^1.5.4", @@ -1446,6 +1448,15 @@ } } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1547,6 +1558,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.8", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", @@ -1560,6 +1591,12 @@ "node": ">=6.0.0" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -2151,6 +2188,12 @@ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, "node_modules/docx": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz", @@ -2201,6 +2244,15 @@ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2740,6 +2792,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.4.2.tgz", + "integrity": "sha512-LVbzjD4WA6UP5B1UnP8wuaXJiLnqMdM/E4fiJXTJ5haJ5b/MBNsK29h2fm6swEoQaVQjvYFWKLE2RanyZIoRVQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/krisk" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3584,6 +3649,17 @@ "loose-envify": "cli.js" } }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3594,6 +3670,39 @@ "yallist": "^3.0.2" } }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/mammoth/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -4613,6 +4722,12 @@ "wrappy": "1" } }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4748,6 +4863,15 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5613,6 +5737,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/standardwebhooks": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", @@ -5889,6 +6019,12 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -6242,6 +6378,15 @@ "xml-js": "bin/cli.js" } }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index da0b673..c799d19 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "docx": "^9.6.1", "express": "^5.2.1", "express-rate-limit": "^8.4.1", + "fuse.js": "^7.4.2", + "mammoth": "^1.12.0", "otplib": "^13.4.0", "prop-types": "^15.8.1", "qrcode": "^1.5.4", diff --git a/server.js b/server.js index 6a80773..0a3a846 100644 --- a/server.js +++ b/server.js @@ -13,6 +13,9 @@ import { loadStudyUsersFromDisk, loadStudyCommunityFromDisk, loadStudyRemindersFromDisk, + loadStudyCommentsFromDisk, + loadStudyCertificatesFromDisk, + loadEpisodeScriptsFromDisk, migrateStudyNotesIfNeeded, loadDownloadCountsFromDisk, loadPodcastChecklistFromDisk, @@ -37,6 +40,9 @@ import { register as registerAdminAssets } from './server/routes/admin-assets.js import { register as registerStudyAuth } from './server/routes/study-auth.js' import { register as registerStudyData } from './server/routes/study-data.js' import { register as registerStudyAccount } from './server/routes/study-account.js' +import { register as registerStudyComments } from './server/routes/study-comments.js' +import { register as registerStudyCertificate } from './server/routes/study-certificate.js' +import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js' import { register as registerContact } from './server/routes/contact.js' import { register as registerQuestions } from './server/routes/questions.js' import { register as registerAnalytics } from './server/routes/analytics.js' @@ -56,6 +62,9 @@ registerAdminAssets(app) registerStudyAuth(app) registerStudyData(app) registerStudyAccount(app) +registerStudyComments(app) +registerStudyCertificate(app) +registerEpisodeScripts(app) registerContact(app) registerQuestions(app) registerAnalytics(app) @@ -93,6 +102,9 @@ Promise.all([ loadStudyUsersFromDisk(), loadStudyCommunityFromDisk(), loadStudyRemindersFromDisk(), + loadStudyCommentsFromDisk(), + loadStudyCertificatesFromDisk(), + loadEpisodeScriptsFromDisk(), migrateStudyNotesIfNeeded(), loadDownloadCountsFromDisk(), loadPodcastChecklistFromDisk(), diff --git a/server/config.js b/server/config.js index 6afc6db..c41f437 100644 --- a/server/config.js +++ b/server/config.js @@ -33,6 +33,10 @@ export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads') export const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json') export const DOWNLOAD_COUNTS_FILE = path.join(DATA_DIR, 'download-counts.json') export const STUDY_REMINDERS_FILE = path.join(DATA_DIR, 'study-reminders.json') +export const STUDY_COMMENTS_FILE = path.join(DATA_DIR, 'study-section-comments.json') +export const STUDY_CERTIFICATES_FILE = path.join(DATA_DIR, 'study-certificates.json') +export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json') +export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon export const DIST_DIR = path.join(ROOT_DIR, 'dist') export const INDEX_FILE = path.join(DIST_DIR, 'index.html') @@ -60,6 +64,8 @@ export const CONTACT_EMAIL_COOLDOWN_MS = Math.max(10 * 1000, Number(process.env. export const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000 export const MAX_QUESTIONS = 1000 +export const MAX_STUDY_COMMENTS = 10000 +export const MAX_STUDY_COMMENT_LENGTH = 2000 export const MAX_STUDY_USERS = 5000 export const MAX_STUDY_ENROLLMENTS_PER_USER = 100 export const MAX_STUDY_NOTES_PER_USER = 500 diff --git a/server/data.js b/server/data.js index f76e38f..d276883 100644 --- a/server/data.js +++ b/server/data.js @@ -17,6 +17,9 @@ import { STUDY_NOTES_FILE, STUDY_PROGRESS_DIR, STUDY_REMINDERS_FILE, + STUDY_COMMENTS_FILE, + STUDY_CERTIFICATES_FILE, + EPISODE_SCRIPTS_FILE, REPLY_TEMPLATES_FILE, REPLY_HISTORY_FILE, PODCAST_CHECKLIST_FILE, @@ -35,6 +38,7 @@ import { MAX_STUDY_ENROLLMENTS_PER_USER, MAX_STUDY_NOTES_PER_USER, MAX_STUDY_NOTE_LENGTH, + MAX_STUDY_COMMENTS, DEFAULT_PODCAST_CHECKLIST_TASKS, buildDefaultPodcastChecklist, } from './config.js' @@ -572,6 +576,75 @@ export async function loadStudyRemindersFromDisk() { } } +// ── Study section comments ───────────────────────────────────────────────── + +export function queueStudyCommentsWrite() { + state.studyCommentsWritePromise = state.studyCommentsWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile(STUDY_COMMENTS_FILE, JSON.stringify(state.studyComments, null, 2), 'utf8') + }) + .catch(err => { + console.error('[study-comments] failed to write:', err) + }) +} + +export async function loadStudyCommentsFromDisk() { + try { + const raw = await readFile(STUDY_COMMENTS_FILE, 'utf8') + const parsed = JSON.parse(raw) + state.studyComments = Array.isArray(parsed) ? parsed.slice(0, MAX_STUDY_COMMENTS) : [] + } catch { + state.studyComments = [] + } +} + +// ── Study certificates ───────────────────────────────────────────────────── + +export function queueStudyCertificatesWrite() { + state.studyCertificatesWritePromise = state.studyCertificatesWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile(STUDY_CERTIFICATES_FILE, JSON.stringify(state.studyCertificates, null, 2), 'utf8') + }) + .catch(err => { + console.error('[study-certificates] failed to write:', err) + }) +} + +export async function loadStudyCertificatesFromDisk() { + try { + const raw = await readFile(STUDY_CERTIFICATES_FILE, 'utf8') + const parsed = JSON.parse(raw) + state.studyCertificates = Array.isArray(parsed) ? parsed : [] + } catch { + state.studyCertificates = [] + } +} + +// ── Episode scripts ─────────────────────────────────────────────────────── + +export function queueEpisodeScriptsWrite() { + state.episodeScriptsWritePromise = state.episodeScriptsWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile(EPISODE_SCRIPTS_FILE, JSON.stringify(state.episodeScripts, null, 2), 'utf8') + }) + .catch(err => { + console.error('[episode-scripts] failed to write:', err) + }) +} + +export async function loadEpisodeScriptsFromDisk() { + try { + const raw = await readFile(EPISODE_SCRIPTS_FILE, 'utf8') + const parsed = JSON.parse(raw) + state.episodeScripts = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {} + } catch { + state.episodeScripts = {} + } +} + // ── Download counts ──────────────────────────────────────────────────────── export function queueDownloadCountsWrite() { diff --git a/server/routes/analytics.js b/server/routes/analytics.js index d7bcf18..ce241fe 100644 --- a/server/routes/analytics.js +++ b/server/routes/analytics.js @@ -163,13 +163,47 @@ export function register(app) { res.json({ ok: true }) }) - app.get('/api/admin-stats', requireAdminAuth, (_req, res) => { - const topPaths = Object.entries(state.hitStats.byPath) - .sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits })) - const topPathsReal = Object.entries(state.hitStats.byPathReal) - .sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits })) - const topPathsBot = Object.entries(state.hitStats.byPathBot) - .sort((a, b) => b[1] - a[1]).slice(0, 10).map(([pathKey, hits]) => ({ path: pathKey, hits })) + app.get('/api/admin-stats', requireAdminAuth, (req, res) => { + // Time-range filter: 7d | 30d | 90d (default: all-time for aggregate, 30d for charts) + const rangeParam = req.query.range + const rangeDays = rangeParam === '7d' ? 7 : rangeParam === '90d' ? 90 : 30 + const rangeLabel = rangeParam === '7d' ? '7d' : rangeParam === '90d' ? '90d' : '30d' + + // Compute a cutoff date string (YYYY-MM-DD) for filtering daily buckets + const cutoffDate = (() => { + const d = new Date() + d.setDate(d.getDate() - (rangeDays - 1)) + return d.toISOString().slice(0, 10) + })() + + // Filter byDayReal/byDayBot keys to only those within the range + const filteredDayKeys = Object.keys(state.hitStats.byDayReal ?? {}).filter(day => day >= cutoffDate) + + // Aggregate hits for the range + const rangeRealHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayReal?.[day] ?? 0), 0) + const rangeBotHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayBot?.[day] ?? 0), 0) + const rangeTotalHits = rangeRealHits + rangeBotHits + + // Filter per-path stats by range — approximate using recent visitor rows scoped to range + const rangeVisitorRows = state.visitorStats.recentVisits.filter(row => { + if (!row.visitedAt) return true // include if no timestamp + return row.visitedAt >= cutoffDate + }) + + const rangePathCountsReal = {} + const rangePathCountsBot = {} + for (const row of rangeVisitorRows) { + const p = row.path ?? '/' + if (row.isBot) rangePathCountsBot[p] = (rangePathCountsBot[p] ?? 0) + 1 + else rangePathCountsReal[p] = (rangePathCountsReal[p] ?? 0) + 1 + } + const topPathsReal = Object.entries(rangePathCountsReal).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([path, hits]) => ({ path, hits })) + const topPathsBot = Object.entries(rangePathCountsBot).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([path, hits]) => ({ path, hits })) + const topPaths = [...topPathsReal, ...topPathsBot].reduce((acc, { path, hits }) => { + const existing = acc.find(x => x.path === path) + if (existing) existing.hits += hits; else acc.push({ path, hits }) + return acc + }, []).sort((a, b) => b.hits - a.hits).slice(0, 10) const last7Days = buildLastNDaysStats(7) const last7DaysReal = last7Days.map(item => ({ day: item.day, hits: state.hitStats.byDayReal?.[item.day] ?? 0 })) @@ -183,7 +217,7 @@ export function register(app) { const botReasons = Object.entries(state.hitStats.botReasons ?? {}) .sort((a, b) => b[1] - a[1]).slice(0, 10).map(([reason, count]) => ({ reason, count })) - const recentVisitorRows = state.visitorStats.recentVisits.slice(0, 100).map(row => { + const recentVisitorRows = rangeVisitorRows.slice(0, 100).map(row => { const fullVisitor = state.visitorStats.visitors[row.visitorId] return { ...row, pageHistory: fullVisitor?.pageHistory ?? [] } }) @@ -217,10 +251,22 @@ export function register(app) { const enrolledUsers = state.studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length const totalEnrollments = Object.values(enrollmentCountsBySlug).reduce((sum, count) => sum + count, 0) + // Enrollment funnel: signups → first study visit → first section completed + const funnelSignups = state.studyUsers.length + const funnelFirstVisit = state.studyUsers.filter(u => u.firstVisitAt).length + const funnelFirstCompletion = state.studyUsers.filter(u => u.firstCompletionAt).length + res.json({ totalHits: state.hitStats.totalHits, - realHits: state.hitStats.realHits ?? 0, - botHits: state.hitStats.botHits ?? 0, + realHits: rangeRealHits, + botHits: rangeBotHits, + rangeTotalHits, + rangeLabel, + rangeDays, + // All-time totals for reference + allTimeRealHits: state.hitStats.realHits ?? 0, + allTimeBotHits: state.hitStats.botHits ?? 0, + allTimeTotalHits: state.hitStats.totalHits, firstHitAt: state.hitStats.firstHitAt, lastHitAt: state.hitStats.lastHitAt, topPaths, topPathsReal, topPathsBot, @@ -273,6 +319,7 @@ export function register(app) { totalEnrollments, enrollmentsByStudy, users, + funnel: { signups: funnelSignups, firstVisit: funnelFirstVisit, firstCompletion: funnelFirstCompletion }, }, }) }) diff --git a/server/routes/episode-scripts.js b/server/routes/episode-scripts.js new file mode 100644 index 0000000..03459b4 --- /dev/null +++ b/server/routes/episode-scripts.js @@ -0,0 +1,134 @@ +import mammoth from 'mammoth' +import { requireAdminAuth } from '../auth.js' +import { state } from '../state.js' +import { queueEpisodeScriptsWrite } from '../data.js' +import { MAX_EPISODE_SCRIPT_LENGTH } from '../config.js' + +/** + * Simple whitespace-normalised search over episode script text. + * Returns { episodeNumber, title, filename, snippet, score } for matches. + */ +function searchScripts(query) { + if (!query || query.trim().length < 2) return [] + const q = query.trim().toLowerCase() + const words = q.split(/\s+/).filter(w => w.length >= 2) + const results = [] + + for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) { + if (!entry?.text) continue + const text = entry.text.toLowerCase() + + // Count how many words appear in the text + const matchCount = words.filter(w => text.includes(w)).length + if (matchCount === 0) continue + + // Find a snippet around the first matching word + const firstWord = words.find(w => text.includes(w)) + const idx = firstWord ? text.indexOf(firstWord) : 0 + const start = Math.max(0, idx - 80) + const end = Math.min(entry.text.length, idx + 180) + let snippet = entry.text.slice(start, end).replace(/\s+/g, ' ').trim() + if (start > 0) snippet = '…' + snippet + if (end < entry.text.length) snippet = snippet + '…' + + results.push({ + episodeNumber, + title: entry.title ?? `Episode ${episodeNumber}`, + filename: entry.filename ?? '', + snippet, + score: matchCount / words.length, // 0–1: fraction of query words found + }) + } + + return results.sort((a, b) => b.score - a.score).slice(0, 8) +} + +export function register(app) { + // Public script search — called by the frontend global search + app.get('/api/episode-scripts/search', (req, res) => { + const q = typeof req.query.q === 'string' ? req.query.q.trim() : '' + if (!q || q.length < 2) { res.json({ results: [] }); return } + res.json({ results: searchScripts(q) }) + }) + + // Admin: list all uploaded scripts + app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => { + const list = Object.entries(state.episodeScripts).map(([episodeNumber, entry]) => ({ + episodeNumber, + title: entry.title ?? `Episode ${episodeNumber}`, + filename: entry.filename ?? '', + uploadedAt: entry.uploadedAt ?? null, + wordCount: entry.text ? entry.text.split(/\s+/).filter(Boolean).length : 0, + })) + list.sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber)) + res.json({ scripts: list }) + }) + + // Admin: upload a docx script for an episode + app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res) => { + const episodeNumber = req.params.episodeNumber?.trim() + if (!episodeNumber || !/^\d+$/.test(episodeNumber)) { + res.status(400).json({ message: 'Episode number must be a positive integer.' }); return + } + + const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : '' + const filename = typeof req.body?.filename === 'string' ? req.body.filename.trim() : `episode-${episodeNumber}.docx` + const title = typeof req.body?.title === 'string' ? req.body.title.trim() : `Episode ${episodeNumber}` + + if (!dataUrl.startsWith('data:')) { + res.status(400).json({ message: 'dataUrl must be a valid data URL.' }); return + } + + const base64 = dataUrl.split(',')[1] ?? '' + if (!base64) { res.status(400).json({ message: 'Empty file.' }); return } + + const buffer = Buffer.from(base64, 'base64') + if (buffer.length === 0 || buffer.length > 20 * 1024 * 1024) { + res.status(400).json({ message: 'File must be between 1 byte and 20MB.' }); return + } + + let text + try { + const result = await mammoth.extractRawText({ buffer }) + text = result.value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim() + } catch (err) { + console.error('[episode-scripts] mammoth extraction failed:', err.message) + res.status(422).json({ message: 'Could not extract text from file. Make sure it is a valid .docx file.' }); return + } + + if (!text || text.length < 10) { + res.status(422).json({ message: 'No readable text found in document.' }); return + } + + // Trim to max length + if (text.length > MAX_EPISODE_SCRIPT_LENGTH) { + text = text.slice(0, MAX_EPISODE_SCRIPT_LENGTH) + } + + state.episodeScripts[episodeNumber] = { + title, + filename, + text, + uploadedAt: new Date().toISOString(), + wordCount: text.split(/\s+/).filter(Boolean).length, + } + queueEpisodeScriptsWrite() + + res.json({ + ok: true, + episodeNumber, + wordCount: state.episodeScripts[episodeNumber].wordCount, + }) + }) + + // Admin: delete a script + app.delete('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, (req, res) => { + const episodeNumber = req.params.episodeNumber?.trim() + if (!state.episodeScripts[episodeNumber]) { + res.status(404).json({ message: 'Script not found.' }); return + } + delete state.episodeScripts[episodeNumber] + queueEpisodeScriptsWrite() + res.json({ ok: true }) + }) +} diff --git a/server/routes/questions.js b/server/routes/questions.js index 20e50cc..4fe32d6 100644 --- a/server/routes/questions.js +++ b/server/routes/questions.js @@ -12,7 +12,14 @@ function ensureDraftQuestions() { export function register(app) { app.get('/api/questions', (_req, res) => { const sourceQuestions = state.draftQuestions ?? state.questions - const publicQuestions = sourceQuestions.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0) + const publicQuestions = sourceQuestions + .filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0) + .sort((a, b) => { + // Pinned questions always float to top + if (a.pinned && !b.pinned) return -1 + if (!a.pinned && b.pinned) return 1 + return 0 + }) res.json({ questions: publicQuestions }) }) @@ -113,4 +120,41 @@ export function register(app) { res.json({ ok: true }) }) + + // Anonymous upvote — no auth required, lightweight increment + app.post('/api/questions/:id/upvote', (req, res) => { + const { id } = req.params + // Look in live questions first, then draft + const liveQ = state.questions.find(q => q.id === id && q.isApproved === true) + const draftQ = state.draftQuestions ? state.draftQuestions.find(q => q.id === id) : null + const question = liveQ ?? draftQ + if (!question) { + res.status(404).json({ message: 'Question not found.' }); return + } + question.upvotes = ((question.upvotes ?? 0) + 1) + if (liveQ) queueQuestionsWrite() + if (draftQ) queueDraftQuestionsWrite() + res.json({ ok: true, upvotes: question.upvotes }) + }) + + // Admin: pin / unpin a question + app.post('/api/admin-questions/:id/pin', requireAdminAuth, (req, res) => { + const { id } = req.params + ensureDraftQuestions() + const question = state.draftQuestions.find(q => q.id === id) + if (!question) { res.status(404).json({ message: 'Question not found.' }); return } + question.pinned = true + queueDraftQuestionsWrite() + res.json({ ok: true, question }) + }) + + app.delete('/api/admin-questions/:id/pin', requireAdminAuth, (req, res) => { + const { id } = req.params + ensureDraftQuestions() + const question = state.draftQuestions.find(q => q.id === id) + if (!question) { res.status(404).json({ message: 'Question not found.' }); return } + question.pinned = false + queueDraftQuestionsWrite() + res.json({ ok: true, question }) + }) } diff --git a/server/routes/study-certificate.js b/server/routes/study-certificate.js new file mode 100644 index 0000000..2cc9fff --- /dev/null +++ b/server/routes/study-certificate.js @@ -0,0 +1,115 @@ +import { randomUUID } from 'node:crypto' +import { requireAdminAuth } from '../auth.js' +import { requireStudyAuth, normalizeStudySlug, isStudyUserEnrolled, getStudyTitleBySlug } from '../study-helpers.js' +import { state } from '../state.js' +import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js' + +/** + * Returns the list of required section IDs for a study (only released sections). + */ +function getRequiredSectionIds(studySlug) { + const content = state.cachedSiteContent + if (!content || !Array.isArray(content.studies)) return null + const study = content.studies.find(s => s?.slug === studySlug) + if (!study || !Array.isArray(study.sections)) return null + return study.sections + .filter(s => s?.status !== 'unreleased' && s?.id) + .map(s => s.id) +} + +export function register(app) { + // GET — check eligibility and return existing certificate token if one exists + app.get('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => { + const user = req.studyUser + const studySlug = normalizeStudySlug(req.params.studySlug) + if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return } + + if (!isStudyUserEnrolled(user, studySlug)) { + res.status(403).json({ message: 'Not enrolled in this study.' }); return + } + + const requiredIds = getRequiredSectionIds(studySlug) + if (!requiredIds || requiredIds.length === 0) { + res.status(404).json({ message: 'Study sections not found.' }); return + } + + const progress = await loadUserProgress(user.id) + const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? [] + const eligible = requiredIds.every(id => completedIds.includes(id)) + + const existing = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug) + + res.json({ + eligible, + studyTitle: getStudyTitleBySlug(studySlug) || studySlug, + completedCount: completedIds.filter(id => requiredIds.includes(id)).length, + totalCount: requiredIds.length, + token: existing?.token ?? null, + issuedAt: existing?.issuedAt ?? null, + }) + }) + + // POST — issue/re-issue a certificate (must be eligible) + app.post('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => { + const user = req.studyUser + const studySlug = normalizeStudySlug(req.params.studySlug) + if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return } + + if (!isStudyUserEnrolled(user, studySlug)) { + res.status(403).json({ message: 'Not enrolled in this study.' }); return + } + + const requiredIds = getRequiredSectionIds(studySlug) + if (!requiredIds || requiredIds.length === 0) { + res.status(404).json({ message: 'Study sections not found.' }); return + } + + const progress = await loadUserProgress(user.id) + const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? [] + const eligible = requiredIds.every(id => completedIds.includes(id)) + + if (!eligible) { + res.status(403).json({ message: 'Complete all sections to earn your certificate.' }); return + } + + // Find or create + let cert = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug) + if (!cert) { + cert = { + id: randomUUID(), + token: randomUUID(), + userId: user.id, + studySlug, + studyTitle: getStudyTitleBySlug(studySlug) || studySlug, + displayName: user.displayName || user.username, + issuedAt: new Date().toISOString(), + } + state.studyCertificates.push(cert) + queueStudyCertificatesWrite() + } + + res.json({ ok: true, token: cert.token, issuedAt: cert.issuedAt }) + }) + + // GET — public certificate page data (no auth, by token) + app.get('/api/public/certificate/:token', (req, res) => { + const { token } = req.params + if (!token || typeof token !== 'string' || token.length > 100) { + res.status(400).json({ message: 'Invalid token.' }); return + } + + const cert = state.studyCertificates.find(c => c.token === token) + if (!cert) { res.status(404).json({ message: 'Certificate not found.' }); return } + + res.json({ + studyTitle: cert.studyTitle, + displayName: cert.displayName, + issuedAt: cert.issuedAt, + }) + }) + + // Admin: list all certificates + app.get('/api/admin-study-certificates', requireAdminAuth, (_req, res) => { + res.json({ certificates: state.studyCertificates }) + }) +} diff --git a/server/routes/study-comments.js b/server/routes/study-comments.js new file mode 100644 index 0000000..934e502 --- /dev/null +++ b/server/routes/study-comments.js @@ -0,0 +1,122 @@ +import { randomUUID } from 'node:crypto' +import { requireAdminAuth } from '../auth.js' +import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, isStudyUserEnrolled } from '../study-helpers.js' +import { state } from '../state.js' +import { queueStudyCommentsWrite } from '../data.js' +import { MAX_STUDY_COMMENTS, MAX_STUDY_COMMENT_LENGTH } from '../config.js' + +export function register(app) { + // GET public-approved comments for a section (no auth) + app.get('/api/study-comments/:studySlug/:sectionId', (req, res) => { + const studySlug = normalizeStudySlug(req.params.studySlug) + const sectionId = normalizeLessonSectionId(req.params.sectionId) + if (!studySlug || !sectionId) { + res.status(400).json({ message: 'Invalid study slug or section id.' }); return + } + + const comments = state.studyComments + .filter(c => c.studySlug === studySlug && c.sectionId === sectionId && c.isApproved === true) + .sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) + .map(c => ({ + id: c.id, + displayName: c.displayName, + text: c.text, + createdAt: c.createdAt, + })) + + res.json({ comments }) + }) + + // POST — enrolled study users post a comment (auto-approved for enrolled users) + app.post('/api/study-comments/:studySlug/:sectionId', requireStudyAuth, (req, res) => { + const user = req.studyUser + const studySlug = normalizeStudySlug(req.params.studySlug) + const sectionId = normalizeLessonSectionId(req.params.sectionId) + + if (!studySlug || !sectionId) { + res.status(400).json({ message: 'Invalid study slug or section id.' }); return + } + + if (!isStudyUserEnrolled(user, studySlug)) { + res.status(403).json({ message: 'Please enroll in this study to leave a comment.' }); return + } + + const text = typeof req.body?.text === 'string' ? req.body.text.trim() : '' + if (!text || text.length < 2 || text.length > MAX_STUDY_COMMENT_LENGTH) { + res.status(400).json({ message: `Comment must be between 2 and ${MAX_STUDY_COMMENT_LENGTH} characters.` }); return + } + + // Auto-approve for enrolled users + const now = new Date().toISOString() + const comment = { + id: randomUUID(), + studySlug, + sectionId, + userId: user.id, + displayName: user.displayName || user.username, + text, + createdAt: now, + isApproved: true, + approvedAt: now, + } + + state.studyComments.unshift(comment) + if (state.studyComments.length > MAX_STUDY_COMMENTS) { + state.studyComments = state.studyComments.slice(0, MAX_STUDY_COMMENTS) + } + queueStudyCommentsWrite() + + res.status(201).json({ + ok: true, + comment: { + id: comment.id, + displayName: comment.displayName, + text: comment.text, + createdAt: comment.createdAt, + }, + }) + }) + + // DELETE — enrolled users can delete their own comment + app.delete('/api/study-comments/:commentId', requireStudyAuth, (req, res) => { + const user = req.studyUser + const { commentId } = req.params + const idx = state.studyComments.findIndex(c => c.id === commentId && c.userId === user.id) + if (idx === -1) { + res.status(404).json({ message: 'Comment not found.' }); return + } + state.studyComments.splice(idx, 1) + queueStudyCommentsWrite() + res.json({ ok: true }) + }) + + // Admin: list all comments (optionally filter by studySlug) + app.get('/api/admin-study-comments', requireAdminAuth, (req, res) => { + const { studySlug, sectionId, approved } = req.query + let comments = state.studyComments + if (studySlug) comments = comments.filter(c => c.studySlug === studySlug) + if (sectionId) comments = comments.filter(c => c.sectionId === sectionId) + if (approved === 'true') comments = comments.filter(c => c.isApproved === true) + if (approved === 'false') comments = comments.filter(c => c.isApproved !== true) + res.json({ comments: comments.slice(0, 500) }) + }) + + // Admin: approve a comment + app.post('/api/admin-study-comments/:commentId/approve', requireAdminAuth, (req, res) => { + const comment = state.studyComments.find(c => c.id === req.params.commentId) + if (!comment) { res.status(404).json({ message: 'Comment not found.' }); return } + comment.isApproved = true + comment.approvedAt = new Date().toISOString() + queueStudyCommentsWrite() + res.json({ ok: true, comment }) + }) + + // Admin: delete any comment + app.delete('/api/admin-study-comments/:commentId', requireAdminAuth, (req, res) => { + const idx = state.studyComments.findIndex(c => c.id === req.params.commentId) + if (idx === -1) { res.status(404).json({ message: 'Comment not found.' }); return } + state.studyComments.splice(idx, 1) + queueStudyCommentsWrite() + res.json({ ok: true }) + }) +} diff --git a/server/routes/study-data.js b/server/routes/study-data.js index 7259a21..59a6611 100644 --- a/server/routes/study-data.js +++ b/server/routes/study-data.js @@ -154,6 +154,13 @@ export function register(app) { const progress = await loadUserProgress(user.id) const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? [] + + // Record first visit timestamp on the user record (additive, never overwrite) + if (!user.firstVisitAt) { + user.firstVisitAt = new Date().toISOString() + queueStudyUsersWrite() + } + res.json({ studySlug, completedSectionIds }) }) @@ -181,6 +188,12 @@ export function register(app) { state.studyProgressCache.set(user.id, progress) queueUserProgressWrite(user.id) + // Record first completion timestamp on the user record (additive, never overwrite) + if (!user.firstCompletionAt) { + user.firstCompletionAt = new Date().toISOString() + queueStudyUsersWrite() + } + res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds }) }) diff --git a/server/state.js b/server/state.js index b818e4a..9176024 100644 --- a/server/state.js +++ b/server/state.js @@ -46,6 +46,16 @@ export const state = { studyReminders: { users: {}, updatedAt: new Date().toISOString() }, studyRemindersWritePromise: Promise.resolve(), + studyComments: [], + studyCommentsWritePromise: Promise.resolve(), + + studyCertificates: [], + studyCertificatesWritePromise: Promise.resolve(), + + // episodeScripts: { [episodeNumber]: { text, title, filename, uploadedAt } } + episodeScripts: {}, + episodeScriptsWritePromise: Promise.resolve(), + downloadCounts: {}, downloadCountsWritePromise: Promise.resolve(), diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index d6c2a4a..9ba2923 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -553,6 +553,7 @@ export interface AdminStats { displayName: string enrolledStudies: Array<{ slug: string; title: string }> }> + funnel?: { signups: number; firstVisit: number; firstCompletion: number } } } @@ -587,6 +588,8 @@ interface Question { answeredAt: string | null isApproved: boolean approvedAt: string | null + upvotes?: number + pinned?: boolean } interface ContactSubmission { @@ -670,7 +673,7 @@ type AdminView = | 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact' | 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series' | 'downloads' | 'custom-links' | 'content-blocks' - | 'questions' | 'analytics' | 'assets' | 'colossians-study' + | 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates' | 'seo' | 'legal' | 'security' | 'brand' | 'global' @@ -707,6 +710,7 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi group: 'Manage', options: [ { value: 'questions', label: 'Questions' }, + { value: 'study-comments', label: 'Study Comments' }, { value: 'emails', label: 'Emails' }, { value: 'contacts', label: 'Contacts' }, { value: 'subscribers', label: 'Subscribers' }, @@ -774,7 +778,7 @@ const ADMIN_SECTION_LINKS: Partial> = { ], } -type PodcastTab = 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series' +type PodcastTab = 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series' | 'episode-scripts' type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' | 'email-templates' @@ -1019,6 +1023,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [podcastTab, setPodcastTab] = useState('current-series') const [stats, setStats] = useState(null) const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading') + const [statsRange, setStatsRange] = useState<'7d' | '30d' | '90d'>('30d') const [maintenanceMsg, setMaintenanceMsg] = useState('') const [opsMsg, setOpsMsg] = useState('') const [backupFiles, setBackupFiles] = useState([]) @@ -1063,6 +1068,23 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [subscriberSearch, setSubscriberSearch] = useState('') const [contactSearch, setContactSearch] = useState('') const [downloadStats, setDownloadStats] = useState>({}) + // Study comments moderation state + interface AdminComment { id: string; studySlug: string; sectionId: string; displayName: string; text: string; createdAt: string; isApproved: boolean; approvedAt: string | null } + const [studyComments, setStudyComments] = useState([]) + const [commentsLoading, setCommentsLoading] = useState(false) + const [commentsLoaded, setCommentsLoaded] = useState(false) + const [commentFilter, setCommentFilter] = useState<'all' | 'pending' | 'approved'>('all') + const [commentSearch, setCommentSearch] = useState('') + + // Episode scripts state + const [scriptsList, setScriptsList] = useState>([]) + const [scriptsLoading, setScriptsLoading] = useState(false) + const [scriptUploadEpisode, setScriptUploadEpisode] = useState('') + const [scriptUploadTitle, setScriptUploadTitle] = useState('') + const [scriptUploadFile, setScriptUploadFile] = useState(null) + const [scriptUploadBusy, setScriptUploadBusy] = useState(false) + const [scriptUploadMsg, setScriptUploadMsg] = useState('') + const [podcastChecklist, setPodcastChecklist] = useState({ tasks: [], episodes: [] }) const [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [podcastChecklistMsg, setPodcastChecklistMsg] = useState('') @@ -1206,7 +1228,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { }, []) useEffect(() => { - fetch('/api/admin-stats') + fetch(`/api/admin-stats?range=${statsRange}`) .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats')))) .then(data => { setStats(data as AdminStats) @@ -1329,8 +1351,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { }) }, [selectedBackup]) - async function reloadStats() { - const r = await fetch('/api/admin-stats') + async function reloadStats(range?: '7d' | '30d' | '90d') { + const r = await fetch(`/api/admin-stats?range=${range ?? statsRange}`) if (!r.ok) throw new Error('Could not refresh stats') const data = await r.json() setStats(data as AdminStats) @@ -2607,6 +2629,99 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { } } + async function handlePinQuestion(questionId: string, pin: boolean) { + try { + const method = pin ? 'POST' : 'DELETE' + const res = await fetch(`/api/admin-questions/${questionId}/pin`, { method }) + if (!res.ok) throw new Error('Failed to update pin') + const data = await res.json().catch(() => ({})) as { question?: Question } + if (data.question) { + setQuestions(items => items.map(q => q.id === questionId ? { ...q, pinned: data.question!.pinned } : q)) + } + } catch (err) { + console.error(err) + } + } + + async function loadStudyComments() { + setCommentsLoading(true) + try { + const res = await fetch('/api/admin-study-comments') + const data = await res.json() + setStudyComments(Array.isArray(data.comments) ? data.comments : []) + setCommentsLoaded(true) + } catch { /* ignore */ } + finally { setCommentsLoading(false) } + } + + async function handleApproveComment(commentId: string) { + try { + const res = await fetch(`/api/admin-study-comments/${commentId}/approve`, { method: 'POST' }) + if (!res.ok) return + setStudyComments(prev => prev.map(c => c.id === commentId ? { ...c, isApproved: true, approvedAt: new Date().toISOString() } : c)) + } catch { /* ignore */ } + } + + async function handleDeleteComment(commentId: string) { + if (!confirm('Delete this comment permanently?')) return + try { + const res = await fetch(`/api/admin-study-comments/${commentId}`, { method: 'DELETE' }) + if (!res.ok) return + setStudyComments(prev => prev.filter(c => c.id !== commentId)) + } catch { /* ignore */ } + } + + async function loadScriptsList() { + setScriptsLoading(true) + try { + const res = await fetch('/api/admin-episode-scripts') + const data = await res.json() + setScriptsList(Array.isArray(data.scripts) ? data.scripts : []) + } catch { /* ignore */ } + finally { setScriptsLoading(false) } + } + + async function handleScriptUpload() { + if (!scriptUploadEpisode.trim() || !/^\d+$/.test(scriptUploadEpisode.trim())) { + setScriptUploadMsg('Enter a valid episode number.'); return + } + if (!scriptUploadFile) { setScriptUploadMsg('Select a .docx file.'); return } + setScriptUploadBusy(true) + setScriptUploadMsg('') + try { + const reader = new FileReader() + const dataUrl = await new Promise((resolve, reject) => { + reader.onload = e => resolve(e.target?.result as string) + reader.onerror = reject + reader.readAsDataURL(scriptUploadFile) + }) + const res = await fetch(`/api/admin-episode-scripts/${encodeURIComponent(scriptUploadEpisode.trim())}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dataUrl, filename: scriptUploadFile.name, title: scriptUploadTitle.trim() || `Episode ${scriptUploadEpisode.trim()}` }), + }) + const data = await res.json() + if (!res.ok) { setScriptUploadMsg(data.message ?? 'Upload failed.'); return } + setScriptUploadMsg(`✓ Episode ${scriptUploadEpisode} uploaded — ${data.wordCount?.toLocaleString()} words indexed.`) + setScriptUploadEpisode('') + setScriptUploadTitle('') + setScriptUploadFile(null) + loadScriptsList() + } catch { + setScriptUploadMsg('Upload failed. Please try again.') + } finally { + setScriptUploadBusy(false) + } + } + + async function handleDeleteScript(episodeNumber: string) { + if (!confirm(`Remove script for Episode ${episodeNumber}?`)) return + try { + await fetch(`/api/admin-episode-scripts/${encodeURIComponent(episodeNumber)}`, { method: 'DELETE' }) + setScriptsList(prev => prev.filter(s => s.episodeNumber !== episodeNumber)) + } catch { alert('Failed to delete script.') } + } + async function handleDeleteQuestion(questionId: string) { if (!confirm('Delete this question permanently?')) return try { @@ -3640,6 +3755,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { + )} @@ -4646,6 +4762,163 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { )} + {/* EPISODE SCRIPTS */} + {(adminView === 'podcast' && podcastTab === 'episode-scripts') && ( +
+
+

Episode Scripts

+

Upload a Word (.docx) script for each episode to make the full content searchable from the site's global search bar.

+
+ + {/* Upload form */} +
+
+ + setScriptUploadEpisode(e.target.value)} + /> +
+
+ + setScriptUploadTitle(e.target.value)} + /> +
+
+ + setScriptUploadFile(e.target.files?.[0] ?? null)} + /> +
+
+ + {scriptUploadMsg && {scriptUploadMsg}} +
+
+ + {/* Indexed scripts list */} +
+

Indexed Scripts

+ +
+ {scriptsList.length === 0 ? ( +

+ No scripts uploaded yet.{' '} + +

+ ) : ( +
+ {scriptsList.map(script => ( +
+
+ Ep. {script.episodeNumber} + {script.title} + {script.wordCount.toLocaleString()} words · {script.filename} +
+ +
+ ))} +
+ )} +
+ )} + + {/* STUDY COMMENTS */} + {adminView === 'study-comments' && (() => { + const filtered = studyComments.filter(c => { + if (commentFilter === 'pending') return !c.isApproved + if (commentFilter === 'approved') return c.isApproved + return true + }).filter(c => { + if (!commentSearch.trim()) return true + const q = commentSearch.toLowerCase() + return c.displayName.toLowerCase().includes(q) || c.text.toLowerCase().includes(q) || c.studySlug.includes(q) || c.sectionId.includes(q) + }) + + return ( +
+
+

Study Comments

+

Review, approve, and delete comments left on study section pages.

+
+ +
+ setCommentSearch(e.target.value)} + placeholder="Search by name, text, or section…" + style={{ minWidth: '280px', maxWidth: '480px', width: '100%' }} + /> + + + + +
+ + {!commentsLoaded ? ( +
+ +
+ ) : filtered.length === 0 ? ( +

+ {commentFilter === 'pending' ? 'No pending comments.' : commentFilter === 'approved' ? 'No approved comments.' : 'No comments yet.'} +

+ ) : ( + <> +

Showing {filtered.length} of {studyComments.length} comments.

+
+ {filtered.map(comment => ( +
+
+
+ {comment.displayName} + {comment.studySlug} › {comment.sectionId} + {new Date(comment.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })} +
+ {!comment.isApproved && ( + Pending + )} + {comment.isApproved && ( + Approved + )} +
+

{comment.text}

+
+ {!comment.isApproved && ( + + )} + +
+
+ ))} +
+ + )} +
+ ) + })()} + {/* EMAIL TEMPLATES */} {adminView === 'email-templates' && ( @@ -4815,6 +5088,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { + )} @@ -4855,6 +5131,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { { + setStatsRange(range) + setStatsStatus('loading') + try { await reloadStats(range) } catch { setStatsStatus('error') } + }} opsStatus={opsStatus} formatDate={formatDate} maskIp={maskIp} diff --git a/src/App.css b/src/App.css index 47262ab..36d3fd8 100644 --- a/src/App.css +++ b/src/App.css @@ -26,8 +26,131 @@ display: flex; align-items: center; justify-content: space-between; + gap: 1rem; } +/* ── Global Search ── */ +/* Search bar strip below the sticky header */ +.site-search-bar { + background: #13120d; + border-bottom: 1px solid #28241a; + position: sticky; + top: 72px; /* sits flush below the header */ + z-index: 99; +} +.site-search-bar-inner { + max-width: 860px; + margin: 0 auto; + padding: 0.5rem 1.5rem; +} + +.global-search { + position: relative; + width: 100%; +} +.global-search-input-wrap { + position: relative; + display: flex; + align-items: center; +} +.global-search-icon { + position: absolute; + left: 0.65rem; + color: #7a7060; + pointer-events: none; + display: flex; + align-items: center; +} +.global-search-input { + width: 100%; + padding: 0.4rem 2rem 0.4rem 2.1rem; + background: rgba(255,255,255,0.05); + border: 1px solid #2a2518; + border-radius: 20px; + color: #f0ead8; + font-size: 0.82rem; + font-family: var(--brand-font-body); + outline: none; + transition: border-color 0.15s, background 0.15s; +} +.global-search-input::placeholder { color: #5a5440; } +.global-search-input:focus { + border-color: rgba(201,168,76,0.5); + background: rgba(255,255,255,0.08); +} +.global-search-input::-webkit-search-cancel-button { display: none; } +.global-search-clear { + position: absolute; + right: 0.55rem; + background: none; + border: none; + color: #7a7060; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + padding: 0 0.2rem; +} +.global-search-clear:hover { color: #f0ead8; } +.global-search-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + min-width: 320px; + background: #18170f; + border: 1px solid #3a3320; + border-radius: 14px; + box-shadow: 0 12px 40px rgba(0,0,0,0.55); + z-index: 200; + overflow: hidden; +} +.global-search-result { + width: 100%; + display: flex; + align-items: flex-start; + gap: 0.7rem; + padding: 0.7rem 1rem; + background: none; + border: none; + border-bottom: 1px solid #25231a; + cursor: pointer; + text-align: left; + color: inherit; + transition: background 0.1s; +} +.global-search-result:last-of-type { border-bottom: none; } +.global-search-result:hover, +.global-search-result--active { background: rgba(201,168,76,0.08); } +.global-search-result-icon { font-size: 1rem; flex-shrink: 0; margin-top: 1px; } +.global-search-result-body { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; } +.global-search-result-title { + color: #f0ead8; + font-size: 0.88rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.global-search-result-meta { display: flex; align-items: center; gap: 0; } +.global-search-result-type { + font-size: 0.72rem; + color: #c9a84c; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.global-search-result-sub { font-size: 0.72rem; color: #7a7060; } +.global-search-result-snippet { + font-size: 0.72rem; + color: #6a6050; + line-height: 1.45; + margin-top: 0.15rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.global-search-empty { padding: 0.85rem 1rem; color: #7a7060; font-size: 0.85rem; margin: 0; } +.global-search-hint { padding: 0.4rem 1rem; color: #3a3320; font-size: 0.7rem; margin: 0; border-top: 1px solid #25231a; } + .header-logo { display: flex; align-items: center; @@ -1661,6 +1784,35 @@ align-items: start; } +.study-breadcrumbs { + margin-bottom: 0.75rem; +} +.study-breadcrumbs-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0; + font-size: 0.8rem; +} +.study-breadcrumbs-link { + color: #7a7060; + text-decoration: none; + transition: color 0.15s; +} +.study-breadcrumbs-link:hover { + color: var(--brand-gold); +} +.study-breadcrumbs-current { + color: #b9b09b; +} +.study-breadcrumbs-sep { + color: #3a3320; + padding: 0 0.25rem; +} + .study-detail-back { display: inline-block; margin-bottom: 0.9rem; @@ -1736,6 +1888,319 @@ margin-top: 0.5rem; } +/* ── Reflection Questions (quiz page) ───────────────────────────────────── */ +.quiz-question-block { + margin-bottom: 1.5rem; +} +.quiz-question-label { + margin: 0 0 0.5rem; + font-weight: 600; + color: var(--brand-warm-white); + line-height: 1.5; +} +.quiz-question-textarea { + width: 100%; + box-sizing: border-box; + padding: 0.9rem; + border-radius: 12px; + border: 1px solid #2a2518; + background: #14130f; + color: #f0ead8; + font-family: var(--brand-font-body); + font-size: 0.95rem; + resize: vertical; +} +.quiz-question-textarea:focus { + outline: none; + border-color: #c8952a; +} +.quiz-share-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; + cursor: pointer; + user-select: none; +} +.quiz-share-toggle input[type="checkbox"] { + accent-color: #e0b840; + width: 15px; + height: 15px; + flex-shrink: 0; +} +.quiz-share-label { + font-size: 0.82rem; + color: #8a7f5a; +} +.quiz-share-label--done { + color: #6fcf97; +} + +/* ── Study Section Comments ──────────────────────────────────────────────── */ +.study-comments { + margin-top: 0.5rem; +} +.study-comments-heading { + font-family: var(--brand-font-header); + font-size: 1.15rem; + color: var(--brand-gold); + margin: 0 0 1rem; +} +.study-comments-loading, +.study-comments-empty, +.study-comments-enroll-note { + font-family: var(--brand-font-body); + color: #7a7060; + font-size: 0.9rem; + margin: 0 0 0.75rem; +} +.study-comments-enroll-note a { + color: var(--brand-gold); + text-decoration: underline; +} +.study-comments-list { + list-style: none; + margin: 0 0 1.25rem; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.study-comment { + background: #14130f; + border: 1px solid #2a2518; + border-radius: 10px; + padding: 0.85rem 1rem; + position: relative; +} +.study-comment-header { + display: flex; + gap: 0.75rem; + align-items: baseline; + margin-bottom: 0.4rem; +} +.study-comment-author { + font-family: var(--brand-font-header); + font-size: 0.9rem; + color: #e0c88a; +} +.study-comment-date { + font-family: var(--brand-font-body); + font-size: 0.78rem; + color: #5a5040; +} +.study-comment-text { + font-family: var(--brand-font-body); + font-size: 0.9rem; + color: #c8bfa8; + margin: 0; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; +} +.study-comment-delete { + margin-top: 0.5rem; + background: none; + border: none; + cursor: pointer; + font-size: 0.78rem; + color: #5a5040; + padding: 0; + text-decoration: underline; +} +.study-comment-delete:hover { color: #e07a7a; } +.study-comment-form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.study-comment-form-label { + font-family: var(--brand-font-body); + font-size: 0.85rem; + color: #8a7f5a; +} +.study-comment-textarea { + width: 100%; + box-sizing: border-box; + background: #14130f; + border: 1px solid #2a2518; + border-radius: 10px; + color: #f0ead8; + font-family: var(--brand-font-body); + font-size: 0.9rem; + padding: 0.75rem 1rem; + resize: vertical; +} +.study-comment-textarea:focus { + outline: none; + border-color: #c8952a; +} +.study-comment-form-footer { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} +.study-comment-char-count { + font-size: 0.78rem; + color: #5a5040; + margin-right: auto; +} +.study-comment-error { + font-size: 0.85rem; + color: #e07a7a; +} +.study-comment-success { + font-size: 0.85rem; + color: #6fcf97; +} +.study-comment-submit { + background: var(--brand-gold); + color: #14100a; + border: none; + border-radius: 8px; + padding: 0.5rem 1.25rem; + font-family: var(--brand-font-header); + font-size: 0.85rem; + cursor: pointer; + font-weight: 700; +} +.study-comment-submit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Study Certificate Panel ──────────────────────────────────────────────── */ +.study-cert-panel { + margin-top: 1.5rem; + background: #14130f; + border: 1px solid #2a2518; + border-radius: 14px; + padding: 1.5rem; +} +.study-cert-heading { + font-family: var(--brand-font-header); + font-size: 1.1rem; + color: var(--brand-gold); + margin: 0 0 1rem; +} +.study-cert-loading { + font-size: 0.9rem; + color: #7a7060; +} +.study-cert-progress-label { + font-size: 0.9rem; + color: #8a7f5a; + margin: 0 0 0.5rem; +} +.study-cert-progress-bar-wrap { + background: #27231b; + border-radius: 999px; + height: 10px; + overflow: hidden; + margin-bottom: 0.75rem; +} +.study-cert-progress-bar { + height: 100%; + background: #e0c070; + border-radius: 999px; +} +.study-cert-note { + font-size: 0.85rem; + color: #5a5040; + margin: 0; +} +.study-cert-eligible { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.study-cert-congrats { + font-size: 0.95rem; + color: #c8bfa8; + margin: 0; +} +.study-cert-error { + font-size: 0.85rem; + color: #e07a7a; +} +.study-cert-btn { + background: var(--brand-gold); + color: #14100a; + border: none; + border-radius: 8px; + padding: 0.6rem 1.4rem; + font-family: var(--brand-font-header); + font-weight: 700; + font-size: 0.9rem; + cursor: pointer; +} +.study-cert-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.study-cert-btn--secondary { + background: transparent; + color: var(--brand-gold); + border: 1px solid var(--brand-gold); +} +.study-cert-issued { + display: flex; + flex-direction: column; + gap: 1rem; +} +.study-cert-issued-label { + font-size: 0.85rem; + color: #7a7060; + margin: 0; +} +.study-cert-canvas { + width: 100%; + max-width: 900px; + height: auto; + border-radius: 8px; +} +.study-cert-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +/* ── Public Certificate Page ──────────────────────────────────────────────── */ +.public-cert-page .thanks-card { + max-width: 540px; +} +.public-cert-title { + font-size: 1.8rem; + color: var(--brand-gold); + margin: 0.5rem 0; +} +.public-cert-divider { + width: 100%; + height: 1px; + background: #2a2518; + margin: 1rem 0; +} +.public-cert-label { + font-size: 0.9rem; + color: #7a7060; + margin: 0.25rem 0; +} +.public-cert-name { + font-family: var(--brand-font-header); + font-size: 1.6rem; + color: var(--brand-warm-white); + margin: 0.25rem 0 0.75rem; +} +.public-cert-study { + font-family: var(--brand-font-header); + font-size: 1.2rem; + color: #e0c88a; + margin: 0.25rem 0 0.75rem; +} +.public-cert-date { + font-size: 0.85rem; + color: #7a7060; + margin: 0; +} + .study-track-switcher { margin: 1rem 0; } @@ -2853,6 +3318,61 @@ padding: 2rem 0; } + .download-filter-bar { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 2rem; + } + + /* Related episodes on download detail pages */ + .download-related-episodes { + margin-top: 2.5rem; + padding-top: 2rem; + border-top: 1px solid #2a2518; + text-align: left; + } + + .download-related-heading { + font-size: 1.1rem; + font-weight: 700; + color: #f0e9cc; + margin: 0 0 1rem; + } + + .download-related-list { + display: grid; + gap: 0.6rem; + } + + .download-related-ep { + display: block; + padding: 0.7rem 0.9rem; + border: 1px solid #2a2518; + border-radius: 10px; + text-decoration: none; + background: #11100d; + transition: border-color 0.15s, background 0.15s; + } + + .download-related-ep:hover { + border-color: rgba(201,168,76,0.4); + background: rgba(201,168,76,0.05); + } + + .download-related-ep-meta { + display: flex; + gap: 0.75rem; + margin-bottom: 0.25rem; + } + + .download-related-ep-title { + margin: 0; + font-size: 0.9rem; + color: #f0ead8; + line-height: 1.4; + } + .download-library-group { background: rgba(17, 17, 17, 0.82); border: 1px solid rgba(201, 168, 76, 0.14); @@ -3461,6 +3981,59 @@ font-size: 1rem; } +/* Enrollment Funnel */ +.admin-funnel { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1.5rem; +} +.admin-funnel-step { + display: flex; + align-items: center; + gap: 1rem; +} +.admin-funnel-bar-wrap { + flex: 1; + background: #2a2620; + border-radius: 4px; + height: 20px; + overflow: hidden; +} +.admin-funnel-bar { + height: 100%; + background: linear-gradient(90deg, #e0b840, #c8952a); + border-radius: 4px; + transition: width 0.4s ease; + min-width: 4px; +} +.admin-funnel-label { + display: flex; + gap: 0.75rem; + align-items: center; + min-width: 260px; +} +.admin-funnel-step-name { + font-family: var(--brand-font-body); + color: var(--brand-warm-white); + font-size: 0.9rem; + flex: 1; +} +.admin-funnel-count { + font-family: var(--brand-font-header); + color: #e0b840; + font-size: 1rem; + min-width: 40px; + text-align: right; +} +.admin-funnel-pct { + font-family: var(--brand-font-body); + color: #8a7f5a; + font-size: 0.85rem; + min-width: 36px; + text-align: right; +} + .admin-stats-lists { margin-top: 1rem; display: grid; @@ -4527,6 +5100,10 @@ position: relative; } + .site-search-bar-inner { + padding: 0.4rem 1rem; + } + .header-menu-btn { display: inline-flex; margin-left: auto; @@ -4971,6 +5548,45 @@ cursor: pointer; } +.qa-card-scene--pinned { + border-color: rgba(201, 168, 76, 0.4) !important; + box-shadow: 0 0 0 1px rgba(201,168,76,0.15); +} + +.qa-pinned-badge { + font-size: 0.85em; + margin-right: 0.15rem; +} + +.qa-upvote-btn { + display: inline-flex; + align-items: center; + gap: 0.3rem; + border-radius: 999px; + padding: 0.28rem 0.65rem; + font-size: 0.73rem; + font-family: var(--brand-font-body); + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + border: 1px solid rgba(201,168,76,0.3); + background: transparent; + color: #c9a84c; + transition: background 0.15s, border-color 0.15s; +} + +.qa-upvote-btn:hover:not(:disabled) { + background: rgba(201,168,76,0.12); + border-color: #c9a84c; +} + +.qa-upvote-btn--voted { + background: rgba(201,168,76,0.1); + border-color: rgba(201,168,76,0.5); + color: #a08030; + cursor: default; +} + .qa-card-actions { display: flex; align-items: center; @@ -5725,6 +6341,124 @@ margin: 0; } + /* Study comments moderation panel */ + .admin-comment-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.75rem; + } + .admin-comment-card { + background: #14130f; + border: 1px solid #2a2518; + border-radius: 10px; + padding: 1rem 1.25rem; + } + .admin-comment-card--pending { + border-color: #5a3a1a; + background: #160f08; + } + .admin-comment-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.5rem; + } + .admin-comment-card-meta { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 0.75rem; + align-items: baseline; + } + .admin-comment-author { + font-family: var(--brand-font-header); + font-size: 0.9rem; + color: var(--brand-warm-white); + } + .admin-comment-location { + font-size: 0.78rem; + color: #8a7f5a; + } + .admin-comment-date { + font-size: 0.75rem; + color: #5a5040; + } + .admin-comment-badge { + font-size: 0.72rem; + font-weight: 700; + padding: 0.2rem 0.55rem; + border-radius: 4px; + white-space: nowrap; + flex-shrink: 0; + } + .admin-comment-badge--pending { + background: #3a2010; + color: #e0903a; + } + .admin-comment-badge--approved { + background: #1a3a2a; + color: #6fcf97; + } + .admin-comment-text { + font-size: 0.9rem; + color: #c8bfa8; + margin: 0 0 0.75rem; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; + } + .admin-comment-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + } + + /* Episode scripts admin panel */ + .admin-script-upload-form { + display: grid; + gap: 1rem; + max-width: 560px; + margin-bottom: 1rem; + } + .admin-script-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.75rem; + } + .admin-script-row { + display: flex; + align-items: center; + gap: 1rem; + background: #14130f; + border: 1px solid #2a2518; + border-radius: 8px; + padding: 0.65rem 1rem; + } + .admin-script-row-info { + flex: 1; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: baseline; + } + .admin-script-ep { + font-family: var(--brand-font-header); + font-size: 0.85rem; + color: var(--brand-gold); + min-width: 48px; + } + .admin-script-title { + font-size: 0.9rem; + color: var(--brand-warm-white); + } + .admin-script-meta { + font-size: 0.78rem; + color: #5a5040; + width: 100%; + } + .admin-sidebar-backdrop { display: none; } diff --git a/src/App.tsx b/src/App.tsx index 2c58182..783c122 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,9 @@ import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySect import { SpotifyIcon } from './icons' import type { SiteContent, ArchivedSeries, StudyProgram } from './content' import { DEFAULTS } from './content' +import { usePageMeta } from './hooks/usePageMeta' +import { useGlobalSearch } from './hooks/useGlobalSearch' +import { GlobalSearch } from './components/GlobalSearch' import './App.css' const SPOTIFY_EMBED_URL = @@ -459,6 +462,17 @@ function formatPubDate(raw: string): string { } } +function SiteSearchBar({ content }: { content: SiteContent }) { + const { query, setQuery, results } = useGlobalSearch(content) + return ( +
+
+ +
+
+ ) +} + function SiteHeader({ content }: { content: SiteContent }) { const [menuOpen, setMenuOpen] = useState(false) const spotifyUrl = content.platformSpotifyUrl || '/spotify' @@ -783,39 +797,72 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) { const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources') const archivedWithResources = (content.archivedSeries ?? []).filter(series => (series.resourceLinks ?? []).length > 0) const [activeTag, setActiveTag] = useState(null) + const [activeType, setActiveType] = useState<'all' | 'resource' | 'series'>('all') if (resources.length === 0 && archivedWithResources.length === 0) return null const allTags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean) - const filteredResources = activeTag ? resources.filter(r => (r.tags ?? []).includes(activeTag)) : resources + + // Filter resources + const filteredResources = resources.filter(r => { + const tagOk = !activeTag || (r.tags ?? []).includes(activeTag) + const typeOk = activeType === 'all' || activeType === 'resource' + return tagOk && typeOk + }) + + // Filter archived series + const filteredArchived = archivedWithResources.filter(() => { + return activeType === 'all' || activeType === 'series' + }) + + const hasTypeFilter = resources.length > 0 && archivedWithResources.length > 0 return (

Download Library

-

More guides, worksheets, and past study downloads.

+

Guides, worksheets, and past study downloads.

- {allTags.length > 0 && ( -
- - {allTags.map(tag => ( + {/* Type + tag filter bar */} +
+ {hasTypeFilter && ( +
0 ? '0.5rem' : 0 }}> + {(['all', 'resource', 'series'] as const).map(t => ( + + ))} +
+ )} + {allTags.length > 0 && (activeType === 'all' || activeType === 'resource') && ( +
- ))} -
- )} + {allTags.map(tag => ( + + ))} +
+ )} +
{filteredResources.length > 0 && (
@@ -824,7 +871,7 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
{resource.imageUrl && ( - {resource.label} + {resource.label} )}
{resource.label} @@ -841,17 +888,17 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
)} - {activeTag !== null && filteredResources.length === 0 && ( + {activeTag !== null && filteredResources.length === 0 && activeType !== 'series' && (

No downloads tagged "{activeTag}".

)} - {archivedWithResources.length > 0 && ( + {filteredArchived.length > 0 && (

Previous Studies

-

Downloads from earlier series that you still want available.

+

Downloads from earlier series.

- {archivedWithResources.map(series => ( + {filteredArchived.map(series => (

{series.title || 'Archived Study'}

@@ -862,7 +909,7 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
{series.imageUrl && ( - {series.title + {series.title )}
{link.label || series.title || 'Download Resource'} @@ -882,10 +929,43 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) { ) } +function useEpisodesForSeries(seriesId: string | null, archivedSeries: ArchivedSeries[]) { + const [episodes, setEpisodes] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!seriesId) return + const series = archivedSeries.find(s => s.id === seriesId) + if (!series?.episodeRange) return + + setLoading(true) + fetch('/api/episodes/all') + .then(r => r.ok ? r.json() : Promise.reject()) + .then((data: { episodes?: Episode[] }) => { + const { from, to } = series.episodeRange! + const filtered = (data.episodes ?? []).filter(ep => { + const num = parseInt(ep.episode, 10) + return !isNaN(num) && num >= from && num <= to + }) + setEpisodes(filtered) + }) + .catch(() => {}) + .finally(() => setLoading(false)) + }, [seriesId]) + + return { episodes, loading } +} + function DownloadDetailPage({ content }: { content: SiteContent }) { const { id } = useParams<{ id: string }>() const resource = resolveDownloadPageResource(content, id) + // Resolve related series for archived resources + const archivedSeriesId = id?.startsWith('archived--') ? id.split('--')[1] : null + const relatedSeries = archivedSeriesId ? (content.archivedSeries ?? []).find(s => s.id === archivedSeriesId) : null + const { episodes: relatedEpisodes, loading: epsLoading } = useEpisodesForSeries(archivedSeriesId, content.archivedSeries ?? []) + const spotifyUrl = content.platformSpotifyUrl || '/episodes' + if (!resource) { return (
@@ -928,6 +1008,40 @@ function DownloadDetailPage({ content }: { content: SiteContent }) { )} Back to Downloads
+ + {relatedSeries && (relatedEpisodes.length > 0 || epsLoading) && ( +
+

+ Episodes from {relatedSeries.title} +

+ {epsLoading ? ( +

Loading episodes…

+ ) : ( +
+ {relatedEpisodes.slice(0, 10).map((ep, idx) => ( + +
+ {ep.episode && Ep. {ep.episode}} + {ep.pubDate && {formatPubDate(ep.pubDate)}} +
+

{ep.title}

+
+ ))} + {relatedEpisodes.length > 10 && ( + + View all {relatedEpisodes.length} episodes → + + )} +
+ )} +
+ )}
) @@ -1175,6 +1289,7 @@ function LandingPage({ content }: { content: SiteContent }) { return (
+ {/* ── HERO ── */} @@ -1417,6 +1532,7 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) { return (
+

Episode not found

@@ -1435,6 +1551,7 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) { return (
+
← Back to Episodes @@ -1494,6 +1611,11 @@ function EpisodesPage({ content }: { content: SiteContent }) { const [allEpisodes, setAllEpisodes] = useState([]) const [loading, setLoading] = useState(true) const [failed, setFailed] = useState(false) + const siteTitle = content.seo?.title || DEFAULTS.seo.title + usePageMeta( + `Episodes | ${siteTitle}`, + content.episodesSeoIntro || 'Browse all episodes of Verse by Verse with Nate — expository Bible teaching, one verse at a time.', + ) useEffect(() => { fetch('/api/episodes/all') @@ -1589,6 +1711,7 @@ function EpisodesPage({ content }: { content: SiteContent }) { return (
+
@@ -1669,6 +1792,7 @@ function EpisodesPage({ content }: { content: SiteContent }) { ) })} + @@ -1678,9 +1802,15 @@ function EpisodesPage({ content }: { content: SiteContent }) { } function ResourcesPage({ content }: { content: SiteContent }) { + const siteTitle = content.seo?.title || DEFAULTS.seo.title + usePageMeta( + `Resources & Downloads | ${siteTitle}`, + 'Study guides, sermon notes, and free resources from Verse by Verse with Nate.', + ) return (
+ @@ -1692,9 +1822,15 @@ function ResourcesPage({ content }: { content: SiteContent }) { } function AboutPage({ content }: { content: SiteContent }) { + const siteTitle = content.seo?.title || DEFAULTS.seo.title + usePageMeta( + `About | ${siteTitle}`, + content.aboutNate || 'Learn about Verse by Verse with Nate — expository Bible teaching from Nate Emmert.', + ) return (
+ @@ -1707,6 +1843,7 @@ function ContactPage({ content }: { content: SiteContent }) { return (
+ @@ -1925,6 +2062,58 @@ function LegalPage({ title, body }: { title: string; body: string[] }) { ) } +function PublicCertificatePage() { + const { token } = useParams<{ token: string }>() + const [cert, setCert] = useState<{ studyTitle: string; displayName: string; issuedAt: string } | null>(null) + const [status, setStatus] = useState<'loading' | 'not-found' | 'ready'>('loading') + + useEffect(() => { + if (!token) { setStatus('not-found'); return } + fetch(`/api/public/certificate/${encodeURIComponent(token)}`) + .then(r => r.ok ? r.json() : Promise.reject()) + .then(data => { setCert(data); setStatus('ready') }) + .catch(() => setStatus('not-found')) + }, [token]) + + usePageMeta( + cert ? `Certificate of Completion – ${cert.studyTitle}` : 'Certificate', + cert ? `${cert.displayName} completed ${cert.studyTitle}` : undefined, + ) + + if (status === 'loading') { + return

Loading certificate…

+ } + if (status === 'not-found' || !cert) { + return ( +
+
+

Certificate Not Found

+

This certificate link is invalid or has been removed.

+ Back to Site +
+
+ ) + } + + return ( +
+
+

Verse by Verse with Nate

+

Certificate of Completion

+
+

This certifies that

+

{cert.displayName}

+

has successfully completed

+

{cert.studyTitle}

+

+ Awarded on {new Date(cert.issuedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })} +

+ Visit Verse by Verse with Nate +
+
+ ) +} + function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: SiteContent) => void }) { const [status, setStatus] = useState<'checking' | 'authenticated' | 'unauthenticated' | 'misconfigured'>('checking') const [adminContent, setAdminContent] = useState(content) @@ -2203,6 +2392,7 @@ function StudyRouteFrame({ content, child }: { content: SiteContent; child: Reac return ( <> + {child} @@ -2303,6 +2493,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> 0 ? Math.round((study.completedLessons / study.totalLessons) * 100) : 0 return ( -
+
+

{study.enrolled ? 'Enrolled' : 'Track'}

{study.title}

@@ -751,6 +756,10 @@ export function StudyLandingPage({ content }: Props) {
+ {progress === 100 && ( + + )} +
) })}
@@ -1209,10 +1218,16 @@ export function ColossiansStudyIndexPage({ content }: Props) { } } + usePageMeta( + `${study.title} | Bible Study`, + study.description, + ) + return (
+

Online Bible Class

{study.title}

{study.description}

@@ -1303,7 +1318,13 @@ export function ColossiansStudyIndexPage({ content }: Props) { {!coming && !enrolled && Enroll to open} {lockedByProgress && Complete previous lesson} {enrolled && !lockedByProgress && studyProgress.completedSectionIds.includes(section.id) && ( - Completed + + + Completed + )}

{section.title}

@@ -1369,9 +1390,10 @@ export function ColossiansStudySectionPage({ content }: Props) { const isEnrolled = isEnrolledInStudy(auth, currentStudySlug) const lessonCompleted = section?.id ? completedSectionIds.includes(section.id) : false - useEffect(() => { - document.title = section && study ? `${section.title} | ${study.title}` : 'Study' - }, [section, study]) + usePageMeta( + section && study ? `${section.title} | ${study.title}` : 'Study', + section?.summary, + ) useEffect(() => { let cancelled = false @@ -1655,6 +1677,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
+ Back to {study.title}

Lesson {lessonNumber} of {sections.length}

{section.title}

@@ -1695,14 +1718,17 @@ export function ColossiansStudySectionPage({ content }: Props) {
-

Discussion Questions

+

Reflection Questions

+

+ Work through these on your own, then share your thoughts in the Discussion below. +

    {section.studyQuestions.map((question, index) => (
  1. {question}
  2. ))}
- Discussion Questions + Write My Answers Go to Community Ask Nate
@@ -1782,6 +1808,14 @@ export function ColossiansStudySectionPage({ content }: Props) {
) })()} + +
+ +