11 Commits

Author SHA1 Message Date
nmemmert 7f56060d6b Beta: admin assets, resource download forms, and resource page redesign 2026-04-30 15:47:10 -04:00
nmemmert 10bd6848ef Simplify podcast highlight labels on episodes page 2026-04-27 16:39:54 -04:00
nmemmert b26103b7b1 Reorganize admin workflow and persist episode discussion questions 2026-04-27 16:25:26 -04:00
nmemmert 83743cc78b Add robots.txt and sitemap.xml for SEO 2026-04-27 15:09:37 -04:00
nmemmert ee153cf9ef image 2026-04-27 11:26:01 -04:00
nmemmert 88860bc42a Serve images first-party and remove necloud domain refs 2026-04-27 10:58:09 -04:00
nmemmert c5cc28b36b Fix podcast art route for Docker runtime dist path 2026-04-27 10:38:57 -04:00
nmemmert 73b8299f1a Add podcast redirects and route through first-party links 2026-04-27 10:31:10 -04:00
nmemmert b4f1c1dbdb Polish About, forms, and share/contact UI 2026-04-23 17:03:41 -04:00
nmemmert 8a71aecb4e Refine homepage sections and contact card design 2026-04-20 15:38:55 -04:00
nmemmert cc4a4b4d63 Gate Titus download, require full name forms, remove companion UI 2026-04-20 11:29:37 -04:00
23 changed files with 4792 additions and 1220 deletions
Binary file not shown.
+1
View File
@@ -18,6 +18,7 @@ RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY server.js ./server.js
COPY --from=build /app/A_Study_of_Titus.pdf ./A_Study_of_Titus.pdf
# Copy seed data to a separate directory so the entrypoint can seed /app/data
# only when no live data exists yet — upgrades never overwrite existing data.
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

+25 -18
View File
@@ -54,15 +54,20 @@ Admin auth:
- Set `ADMIN_PASSWORD` on the server/container to protect `/admin` and admin stats/maintenance endpoints.
- Without `ADMIN_PASSWORD`, admin login is disabled until configured.
- Optional: set `CACHE_PURGE_WEBHOOK_URL` to enable one-click cache purge from Admin Operations.
- Optional: set `DEPLOY_WEBHOOK_URL` to enable one-click deploy trigger from Admin Operations.
- Optional: set `BUILD_COMMIT`, `BUILD_NUMBER`, and `DEPLOYED_AT` to display deployment status in Admin.
The app will be available at `http://localhost:4173`.
Persistent admin saves:
- Admin updates are written to `data/admin-content.json`.
- Admin drafts are written to `data/admin-content-draft.json`.
- Built-in page hit stats are written to `data/hit-stats.json`.
- Detailed visitor analytics are written to `data/visitor-stats.json`.
- Backup snapshots are written to `data/backups/`.
- Uploaded admin assets are stored in `data/uploads/` and served at `/uploads/*`.
- `docker-compose.yml` mounts `./data` into the container at `/app/data`.
- This keeps all admin-managed data after container restarts/rebuilds/updates.
@@ -75,18 +80,20 @@ Persistent admin saves:
## Routes
- Portfolio home: `/`
- Project details: `/projects/:slug`
- Site home: `/`
- Start Here: `/start-here`
- Questions: `/questions`
- Privacy: `/privacy`
- Terms: `/terms`
- Admin editor: `/admin`
- Admin draft preview: `/preview`
## Admin editing
- Open `/admin` to edit project card and featured-page content.
- Use **Global Settings** to update theme, home headers, and quick tips.
- Use **Project Settings** to update project-specific fields and README URL.
- Use **Create new entry** to add a new project directly from Admin.
- Enter a URL and click **Scan URL metadata** to auto-fill title, summary, domain, category, and icon when available.
- Click **Save project** to apply updates instantly.
- Open `/admin` to edit homepage content, links, archived studies, Q&A moderation, and analytics.
- Use **Operations** to manage SEO metadata, sitemap paths, legal page text, redirect rules, and podcast highlights.
- Use **Operations** to upload/delete hosted image assets for use in content and social cards.
- Use **Save Draft**, **Preview Draft**, and **Publish Draft** for a safer publish workflow.
- Saved edits are written to `data/admin-content.json` through the API server.
- Built-in stats in `/admin` include page hits plus visitor details (IP, country/state/county/city, returning visitors, and recent visitor log).
- Site Stats in `/admin` includes a Bible Questions inbox sourced from contact form submissions marked as Bible Question.
@@ -94,19 +101,19 @@ Persistent admin saves:
- Admin now includes maintenance actions: **Export JSON**, **Backup Now**, **Prune Old Data**, and **Clear Analytics**.
- Admin also supports restoring from a backup snapshot from `/admin`.
- The server creates startup + daily backup snapshots and retains recent backups automatically.
- Use the **Theme** dropdown to switch between Sandstone, Ocean, Midnight, Forest, and Sunset.
- Use **Remove project** to delete the selected project from your local Admin data.
- Use **Reset project** or **Reset all** to restore defaults from `src/data/projects.ts`.
Scan notes:
- URL scanning is best-effort and depends on site accessibility.
- Some sites block direct scanning; Admin falls back to a proxy scan path when possible.
- Always review scanned fields before saving.
- Operations can trigger optional **Purge Cache** and **Trigger Deploy** webhooks.
Deployment note:
- To keep Admin saves working on the internet, deploy with the Node API (`server.js`) and writable server storage for `data/admin-content.json`.
- To keep Admin saves working on the internet, deploy with the Node API (`server.js`) and writable server storage for `data/`.
Titus study download gate:
- The site now gates Titus study downloads behind a name/email form.
- Default source file is `A_Study_of_Titus.pdf` in the project root.
- Override source path with `TITUS_STUDY_FILE` (relative to project root or absolute).
- Override downloaded filename with `TITUS_STUDY_DOWNLOAD_NAME`.
- When the form checkbox is left enabled (default), contacts are synced to Resend using the same contact sync flow as the contact form.
Useful container commands:
+112
View File
@@ -0,0 +1,112 @@
{
"siteContent": {
"eyebrow": "A Journey Through Scripture",
"heroTagline": "Exploring God's Word one verse at a time",
"startHereHeading": "New Here? Start Here",
"startHereIntro": "If this is your first visit, this path helps you get grounded quickly and make the most of the site.",
"startHereStep1Title": "Step 1: Listen to 3 Starter Episodes",
"startHereStep1Body": "Start with the newest episode, one from the beginning of the current study, and one from the middle.",
"startHereStep1Cta": "Open Episodes",
"startHereStep2Title": "Step 2: Use Q&A to Go Deeper",
"startHereStep2Body": "Browse by topic or search key words to find concise biblical answers and related follow-up questions.",
"startHereStep2Cta": "Go to Q&A",
"startHereStep3Title": "Step 3: Ask Nate Directly",
"startHereStep3Body": "Use the contact form to submit your Bible question for future Q&A or an upcoming episode.",
"startHereStep3Cta": "Submit a Question",
"aboutShowHeading": "Depth. Clarity. Application.",
"aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.",
"aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
"aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
"aboutPhotoUrl": "/images/nate-photo.jpeg",
"aboutVerseArtUrl": "/images/hebrews-4-12-verse-art.png",
"contactPhotoUrl": "/images/nate-contact-photo.png",
"seriesLabel": "Now Playing",
"seriesTitle": "Study of Titus: Sound Doctrine",
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
"seriesImageUrl": "/uploads/titus-series-1777568297943.png",
"seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
"studyGuideTitle": "Companion Study Guide",
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
"shareHeading": "Help one more person hear the Word this week.",
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
"customLinks": [
{
"id": "molso9ow",
"label": "test",
"url": "/uploads/discussion_questions_post-1777565348551.png",
"imageUrl": "/uploads/discussion_questions_post-1777565348551.png",
"placement": "resources"
}
],
"customBlocks": [],
"archivedSeries": [],
"redirects": [
{
"id": "spotify",
"path": "/spotify",
"target": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
"statusCode": 301
},
{
"id": "apple",
"path": "/apple",
"target": "https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate",
"statusCode": 301
},
{
"id": "amazon",
"path": "/amazon",
"target": "https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate",
"statusCode": 301
}
],
"podcastFeaturedLinks": [
{
"id": "sample-highlight-1",
"title": "Introduction to Titus",
"episodeNumber": "2",
"summary": "A practical walkthrough of context, structure, and application for this study.",
"url": "https://open.spotify.com/episode/3mpFfJQP6avAhzxu4BTKvR?si=cl_Dm_2vQraiboouaoINaw",
"embedUrl": "",
"showNotes": "",
"discussionQuestions": [
"1. What do you already know about Paul, Titus, or the island of Crete? And what — if anything — surprised you about the background of this letter?",
"2. The letters core theme is that sound doctrine leads to godly living. Where have you seen that connection — or its breakdown — in your own experience?",
"3. Paul trusted Titus with a hard assignment. Who has trusted you with something difficult — and what did that trust mean to you?"
]
}
],
"seo": {
"title": "Verse by Verse with Nate",
"description": "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
"ogTitle": "Verse by Verse with Nate",
"ogDescription": "A Journey Through Scripture - verse by verse, nugget by nugget.",
"ogImage": "/images/podcast-art.jpeg",
"canonicalUrl": "https://versebyversewithnate.us/",
"robotsPolicy": "index,follow",
"sitemapPaths": [
"/",
"/start-here",
"/questions",
"/privacy",
"/terms"
]
},
"legal": {
"privacyTitle": "Privacy Policy",
"privacyBody": [
"We respect your privacy and collect limited data to operate and improve this site.",
"If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.",
"Contact form details are used only to respond to your message and ministry communication requests."
],
"termsTitle": "Terms",
"termsBody": [
"Content on this site is for informational and ministry purposes.",
"External links are provided for convenience and are subject to third-party policies.",
"By using this site, you agree to lawful use and respectful communication."
]
}
},
"updatedAt": "2026-04-30T18:06:00.013Z"
}
+95 -7
View File
@@ -2,23 +2,111 @@
"siteContent": {
"eyebrow": "A Journey Through Scripture",
"heroTagline": "Exploring God's Word one verse at a time",
"startHereHeading": "New Here? Start Here",
"startHereIntro": "If this is your first visit, this path helps you get grounded quickly and make the most of the site.",
"startHereStep1Title": "Step 1: Listen to 3 Starter Episodes",
"startHereStep1Body": "Start with the newest episode, one from the beginning of the current study, and one from the middle.",
"startHereStep1Cta": "Open Episodes",
"startHereStep2Title": "Step 2: Use Q&A to Go Deeper",
"startHereStep2Body": "Browse by topic or search key words to find concise biblical answers and related follow-up questions.",
"startHereStep2Cta": "Go to Q&A",
"startHereStep3Title": "Step 3: Ask Nate Directly",
"startHereStep3Body": "Use the contact form to submit your Bible question for future Q&A or an upcoming episode.",
"startHereStep3Cta": "Submit a Question",
"aboutShowHeading": "Depth. Clarity. Application.",
"aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.",
"aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
"aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He created Verse by Verse to share the joy of deep Scripture study in a format anyone can follow along with — no seminary required.",
"aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
"aboutPhotoUrl": "/images/nate-photo.jpeg",
"aboutVerseArtUrl": "/images/hebrews-4-12-verse-art.png",
"contactPhotoUrl": "/images/nate-contact-photo.png",
"seriesLabel": "Now Playing",
"seriesTitle": "Study of Titus: Sound Doctrine",
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
"seriesImageUrl": "/images/titus-cover.png",
"seriesImageUrl": "/uploads/titus-series-1777568297943.png",
"seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
"studyGuideTitle": "Companion Study Guide",
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
"shareHeading": "Help one more person hear the Word this week.",
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
"customLinks": [],
"customBlocks": [],
"archivedSeries": []
},
"updatedAt": "2026-04-16T12:13:53.095Z"
"customLinks": [
{
"id": "molso9ow",
"label": "test",
"url": "/uploads/discussion_questions_post-1777565348551.png",
"imageUrl": "/uploads/discussion_questions_post-1777565348551.png",
"placement": "resources"
}
],
"customBlocks": [],
"archivedSeries": [],
"redirects": [
{
"id": "spotify",
"path": "/spotify",
"target": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
"statusCode": 301
},
{
"id": "apple",
"path": "/apple",
"target": "https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate",
"statusCode": 301
},
{
"id": "amazon",
"path": "/amazon",
"target": "https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate",
"statusCode": 301
}
],
"podcastFeaturedLinks": [
{
"id": "sample-highlight-1",
"title": "Introduction to Titus",
"episodeNumber": "2",
"summary": "A practical walkthrough of context, structure, and application for this study.",
"url": "https://open.spotify.com/episode/3mpFfJQP6avAhzxu4BTKvR?si=cl_Dm_2vQraiboouaoINaw",
"embedUrl": "",
"showNotes": "",
"discussionQuestions": [
"1. What do you already know about Paul, Titus, or the island of Crete? And what — if anything — surprised you about the background of this letter?",
"2. The letters core theme is that sound doctrine leads to godly living. Where have you seen that connection — or its breakdown — in your own experience?",
"3. Paul trusted Titus with a hard assignment. Who has trusted you with something difficult — and what did that trust mean to you?"
]
}
],
"seo": {
"title": "Verse by Verse with Nate",
"description": "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
"ogTitle": "Verse by Verse with Nate",
"ogDescription": "A Journey Through Scripture - verse by verse, nugget by nugget.",
"ogImage": "/images/podcast-art.jpeg",
"canonicalUrl": "https://versebyversewithnate.us/",
"robotsPolicy": "index,follow",
"sitemapPaths": [
"/",
"/start-here",
"/questions",
"/privacy",
"/terms"
]
},
"legal": {
"privacyTitle": "Privacy Policy",
"privacyBody": [
"We respect your privacy and collect limited data to operate and improve this site.",
"If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.",
"Contact form details are used only to respond to your message and ministry communication requests."
],
"termsTitle": "Terms",
"termsBody": [
"Content on this site is for informational and ministry purposes.",
"External links are provided for convenience and are subject to third-party policies.",
"By using this site, you agree to lawful use and respectful communication."
]
}
},
"updatedAt": "2026-04-30T18:06:00.017Z"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+4 -4
View File
@@ -11,14 +11,14 @@
<meta property="og:type" content="website" />
<meta property="og:title" content="Verse by Verse with Nate" />
<meta property="og:description" content="A verse-by-verse Scripture podcast exploring God's Word with depth, clarity, and practical application." />
<meta property="og:image" content="https://necloud.us/images/banner.png" />
<meta property="og:image" content="https://versebyversewithnate.us/images/banner.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:secure_url" content="https://necloud.us/images/banner.png" />
<meta property="og:image:secure_url" content="https://versebyversewithnate.us/images/banner.png" />
<meta property="og:image:alt" content="Verse by Verse with Nate podcast banner" />
<meta property="og:locale" content="en_US" />
<meta property="og:url" content="https://necloud.us" />
<meta property="og:url" content="https://versebyversewithnate.us" />
<meta property="og:site_name" content="Verse by Verse with Nate" />
<link rel="canonical" href="https://necloud.us" />
<link rel="canonical" href="https://versebyversewithnate.us" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,600;1,700&display=swap" rel="stylesheet" />
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

+9
View File
@@ -0,0 +1,9 @@
User-agent: *
Allow: /
Disallow: /admin
Disallow: /api/
Disallow: /thanks
Disallow: /subscribe/thanks
Sitemap: https://versebyversewithnate.us/sitemap.xml
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://versebyversewithnate.us/</loc>
<lastmod>2026-04-27</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/start-here</loc>
<lastmod>2026-04-27</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/questions</loc>
<lastmod>2026-04-27</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/subscribe</loc>
<lastmod>2026-04-27</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/privacy</loc>
<lastmod>2026-04-27</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/terms</loc>
<lastmod>2026-04-27</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>
+923 -152
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
import { createHash, randomUUID } from 'node:crypto'
import { parseCookies } from './helpers.js'
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
const adminSessions = new Map()
function cookieFlags() {
return process.env.NODE_ENV === 'production' ? '; Secure' : ''
}
export function sha256(value) {
return createHash('sha256').update(String(value)).digest('hex')
}
export function isAdminPasswordConfigured() {
return Boolean(ADMIN_PASSWORD)
}
export function validateAdminPasswordSetup() {
if (!isAdminPasswordConfigured() && process.env.NODE_ENV === 'production') {
throw new Error('ADMIN_PASSWORD is required in production.')
}
if (!isAdminPasswordConfigured()) {
console.warn('ADMIN_PASSWORD is not configured; admin routes will remain disabled until the environment is configured.')
}
}
export function isAdminPasswordValid(password) {
if (!isAdminPasswordConfigured()) return false
return sha256(password) === sha256(ADMIN_PASSWORD)
}
export function createAdminSession() {
const token = randomUUID()
adminSessions.set(token, Date.now() + ADMIN_SESSION_TTL_MS)
return token
}
export function deleteAdminSession(token) {
if (token) {
adminSessions.delete(token)
}
}
export function isValidAdminSession(req) {
if (!isAdminPasswordConfigured()) return false
const cookies = parseCookies(req.headers.cookie)
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
if (!sessionToken) return false
const expiresAt = adminSessions.get(sessionToken)
if (!expiresAt || expiresAt <= Date.now()) {
adminSessions.delete(sessionToken)
return false
}
adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS)
return true
}
export function setAdminSessionCookie(res, token) {
res.append(
'Set-Cookie',
`${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
)
}
export function clearAdminSessionCookie(res) {
res.append(
'Set-Cookie',
`${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
)
}
export function requireAdminAuth(req, res, next) {
if (!isValidAdminSession(req)) {
res.status(401).json({ message: 'Unauthorized' })
return
}
next()
}
+412
View File
@@ -0,0 +1,412 @@
import { randomUUID } from 'node:crypto'
export const DEFAULT_REDIRECT_RULES = [
{
id: 'spotify',
path: '/spotify',
target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
statusCode: 301,
},
{
id: 'apple',
path: '/apple',
target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
statusCode: 301,
},
{
id: 'amazon',
path: '/amazon',
target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
statusCode: 301,
},
]
export const DEFAULT_SEO = {
title: 'Verse by Verse with Nate',
description: 'Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.',
ogTitle: 'Verse by Verse with Nate',
ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.',
ogImage: '/images/podcast-art.jpeg',
canonicalUrl: 'https://versebyversewithnate.us/',
robotsPolicy: 'index,follow',
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'],
}
export const DEFAULT_LEGAL = {
privacyTitle: 'Privacy Policy',
privacyBody: [
'We respect your privacy and collect limited data to operate and improve this site.',
'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
'Contact form details are used only to respond to your message and ministry communication requests.',
],
termsTitle: 'Terms',
termsBody: [
'Content on this site is for informational and ministry purposes.',
'External links are provided for convenience and are subject to third-party policies.',
'By using this site, you agree to lawful use and respectful communication.',
],
}
export const DEFAULT_PODCAST_FEATURED_LINKS = []
export const DEFAULT_PUBLISH_STATE = {
draftUpdatedAt: null,
publishedAt: null,
}
export function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
export function splitName(fullName) {
const parts = String(fullName).trim().split(/\s+/).filter(Boolean)
return {
firstName: parts[0] ?? '',
lastName: parts.slice(1).join(' '),
}
}
export function sanitizeUrl(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed.startsWith('/')) return trimmed
if (/^https?:\/\//i.test(trimmed)) return trimmed
return ''
}
export function normalizeRedirectPath(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
if (!trimmed) return ''
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
const normalized = withSlash.replace(/\/+/g, '/')
if (normalized === '/') return ''
if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return ''
return normalized
}
export function normalizeSitemapPath(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed === '/') return '/'
return normalizeRedirectPath(trimmed)
}
export function sanitizeRedirectRules(value) {
const source = Array.isArray(value) ? value : []
const seen = new Set()
const out = []
for (const item of source) {
const pathValue = normalizeRedirectPath(item?.path)
const target = sanitizeUrl(item?.target)
const statusCode = Number(item?.statusCode) === 302 ? 302 : 301
if (!pathValue || !target) continue
if (seen.has(pathValue)) continue
seen.add(pathValue)
out.push({
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
path: pathValue,
target,
statusCode,
})
}
return out.length > 0 ? out : DEFAULT_REDIRECT_RULES
}
export function sanitizeFeaturedLinks(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => {
const discussionQuestions = Array.isArray(item.discussionQuestions)
? item.discussionQuestions
.filter(question => typeof question === 'string')
.map(question => question.trim())
.filter(Boolean)
.slice(0, 30)
: []
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
url: sanitizeUrl(item.url),
embedUrl: sanitizeUrl(item.embedUrl),
showNotes: typeof item.showNotes === 'string' ? item.showNotes.trim().slice(0, 10000) : '',
discussionQuestions,
}
})
.filter(
item =>
item.title ||
item.summary ||
item.url ||
item.embedUrl ||
item.showNotes ||
item.discussionQuestions.length > 0,
)
}
function sanitizeCustomLinks(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => {
const placement =
item?.placement === 'platforms' || item?.placement === 'footer' || item?.placement === 'resources'
? item.placement
: 'footer'
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '',
url: sanitizeUrl(item.url),
imageUrl: sanitizeUrl(item.imageUrl),
placement,
}
})
.filter(item => item.label && item.url)
}
function sanitizeCustomBlocks(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '',
body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '',
}))
.filter(item => item.heading || item.body)
}
function sanitizeArchivedSeriesResourceLinks(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
label: typeof item.label === 'string' ? item.label.trim().slice(0, 120) : '',
url: sanitizeUrl(item.url),
}))
.filter(item => item.label && item.url)
}
function sanitizeArchivedSeriesNotes(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '',
body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '',
}))
.filter(item => item.heading || item.body)
}
function sanitizeArchivedSeries(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '',
title: typeof item.title === 'string' ? item.title.trim().slice(0, 200) : '',
description: typeof item.description === 'string' ? item.description.trim().slice(0, 1000) : '',
imageUrl: sanitizeUrl(item.imageUrl),
listenUrl: sanitizeUrl(item.listenUrl),
studyGuideTitle: typeof item.studyGuideTitle === 'string' ? item.studyGuideTitle.trim().slice(0, 140) : '',
studyGuideDescription: typeof item.studyGuideDescription === 'string' ? item.studyGuideDescription.trim().slice(0, 4000) : '',
studyGuideUrl: sanitizeUrl(item.studyGuideUrl),
resourceLinks: sanitizeArchivedSeriesResourceLinks(item.resourceLinks),
notes: sanitizeArchivedSeriesNotes(item.notes),
}))
.filter(
item =>
item.title ||
item.description ||
item.resourceLinks.length > 0 ||
item.notes.length > 0,
)
}
export function sanitizeSiteContent(siteContent) {
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return {}
const seo = siteContent.seo && typeof siteContent.seo === 'object' ? siteContent.seo : {}
const legal = siteContent.legal && typeof siteContent.legal === 'object' ? siteContent.legal : {}
return {
...siteContent,
customLinks: sanitizeCustomLinks(siteContent.customLinks),
customBlocks: sanitizeCustomBlocks(siteContent.customBlocks),
archivedSeries: sanitizeArchivedSeries(siteContent.archivedSeries),
redirects: sanitizeRedirectRules(siteContent.redirects),
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
seo: {
title: typeof seo.title === 'string' && seo.title.trim() ? seo.title.trim().slice(0, 120) : DEFAULT_SEO.title,
description: typeof seo.description === 'string' && seo.description.trim() ? seo.description.trim().slice(0, 240) : DEFAULT_SEO.description,
ogTitle: typeof seo.ogTitle === 'string' && seo.ogTitle.trim() ? seo.ogTitle.trim().slice(0, 120) : DEFAULT_SEO.ogTitle,
ogDescription: typeof seo.ogDescription === 'string' && seo.ogDescription.trim() ? seo.ogDescription.trim().slice(0, 240) : DEFAULT_SEO.ogDescription,
ogImage: sanitizeUrl(seo.ogImage) || DEFAULT_SEO.ogImage,
canonicalUrl: sanitizeUrl(seo.canonicalUrl) || DEFAULT_SEO.canonicalUrl,
robotsPolicy: typeof seo.robotsPolicy === 'string' && seo.robotsPolicy.trim() ? seo.robotsPolicy.trim() : DEFAULT_SEO.robotsPolicy,
sitemapPaths: Array.isArray(seo.sitemapPaths)
? seo.sitemapPaths.map(pathItem => normalizeSitemapPath(pathItem)).filter(Boolean)
: [...DEFAULT_SEO.sitemapPaths],
},
legal: {
privacyTitle: typeof legal.privacyTitle === 'string' && legal.privacyTitle.trim() ? legal.privacyTitle.trim().slice(0, 120) : DEFAULT_LEGAL.privacyTitle,
privacyBody: Array.isArray(legal.privacyBody) && legal.privacyBody.length > 0
? legal.privacyBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
: [...DEFAULT_LEGAL.privacyBody],
termsTitle: typeof legal.termsTitle === 'string' && legal.termsTitle.trim() ? legal.termsTitle.trim().slice(0, 120) : DEFAULT_LEGAL.termsTitle,
termsBody: Array.isArray(legal.termsBody) && legal.termsBody.length > 0
? legal.termsBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
: [...DEFAULT_LEGAL.termsBody],
},
}
}
export function escapeXml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
export function buildAbsoluteUrl(baseUrl, maybeRelativePath) {
const safeBase = typeof baseUrl === 'string' && baseUrl.trim() ? baseUrl.trim() : DEFAULT_SEO.canonicalUrl
const root = safeBase.endsWith('/') ? safeBase.slice(0, -1) : safeBase
if (typeof maybeRelativePath !== 'string' || !maybeRelativePath.trim()) return root
const value = maybeRelativePath.trim()
if (/^https?:\/\//i.test(value)) return value
if (value.startsWith('/')) return `${root}${value}`
return `${root}/${value}`
}
export function injectSeoIntoHtml(html, siteContent) {
const seo = siteContent?.seo ?? DEFAULT_SEO
const title = seo.title || DEFAULT_SEO.title
const description = seo.description || DEFAULT_SEO.description
const ogTitle = seo.ogTitle || title
const ogDescription = seo.ogDescription || description
const canonical = buildAbsoluteUrl(seo.canonicalUrl || DEFAULT_SEO.canonicalUrl, '/')
const ogImage = buildAbsoluteUrl(canonical, seo.ogImage || DEFAULT_SEO.ogImage)
const robots = seo.robotsPolicy || DEFAULT_SEO.robotsPolicy
return html
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${escapeHtml(title)}</title>`)
.replace(/<meta name="description" content="[^"]*"\s*\/?>/i, `<meta name="description" content="${escapeHtml(description)}" />`)
.replace(/<meta name="robots" content="[^"]*"\s*\/?>/i, `<meta name="robots" content="${escapeHtml(robots)}" />`)
.replace(/<meta property="og:title" content="[^"]*"\s*\/?>/i, `<meta property="og:title" content="${escapeHtml(ogTitle)}" />`)
.replace(/<meta property="og:description" content="[^"]*"\s*\/?>/i, `<meta property="og:description" content="${escapeHtml(ogDescription)}" />`)
.replace(/<meta property="og:image" content="[^"]*"\s*\/?>/i, `<meta property="og:image" content="${escapeHtml(ogImage)}" />`)
.replace(/<meta property="og:image:secure_url" content="[^"]*"\s*\/?>/i, `<meta property="og:image:secure_url" content="${escapeHtml(ogImage)}" />`)
.replace(/<meta property="og:url" content="[^"]*"\s*\/?>/i, `<meta property="og:url" content="${escapeHtml(canonical)}" />`)
.replace(/<link rel="canonical" href="[^"]*"\s*\/?>/i, `<link rel="canonical" href="${escapeHtml(canonical)}" />`)
}
export function normalizeAssetBaseName(name) {
if (typeof name !== 'string') return `upload-${Date.now()}`
const cleaned = name
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return cleaned || `upload-${Date.now()}`
}
export function inferImageExtensionFromDataUrl(dataUrl) {
if (typeof dataUrl !== 'string') return null
if (dataUrl.startsWith('data:image/png;base64,')) return '.png'
if (dataUrl.startsWith('data:image/jpeg;base64,')) return '.jpg'
if (dataUrl.startsWith('data:image/webp;base64,')) return '.webp'
if (dataUrl.startsWith('data:image/gif;base64,')) return '.gif'
return null
}
export function normalizeIp(rawIp) {
if (!rawIp) return 'unknown'
let ip = String(rawIp).trim()
if (ip.includes(',')) {
ip = ip.split(',')[0].trim()
}
if (ip.startsWith('::ffff:')) {
ip = ip.slice(7)
}
if (ip === '::1') {
ip = '127.0.0.1'
}
return ip || 'unknown'
}
export function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for']
if (forwarded) {
return normalizeIp(forwarded)
}
return normalizeIp(req.ip)
}
export function parseCookies(cookieHeader) {
if (!cookieHeader) return {}
return cookieHeader
.split(';')
.map(v => v.trim())
.filter(Boolean)
.reduce((acc, part) => {
const idx = part.indexOf('=')
if (idx === -1) return acc
const key = part.slice(0, idx).trim()
const value = part.slice(idx + 1).trim()
try {
acc[key] = decodeURIComponent(value)
} catch {
acc[key] = value
}
return acc
}, {})
}
export function hasVisitorConsent(req) {
const cookies = parseCookies(req.headers.cookie)
return cookies['vbn_analytics_consent'] === 'yes'
}
export function setConsentCookie(res, consent) {
const value = consent ? 'yes' : 'no'
const secureFlag = process.env.NODE_ENV === 'production' ? '; Secure' : ''
res.append('Set-Cookie', `vbn_analytics_consent=${value}; Max-Age=31536000; Path=/; SameSite=Lax${secureFlag}`)
}
export function isPrivateOrLocalIp(ip) {
return (
ip === '127.0.0.1' ||
ip === 'localhost' ||
ip.startsWith('10.') ||
ip.startsWith('192.168.') ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip) ||
ip.startsWith('fc') ||
ip.startsWith('fd') ||
ip.startsWith('fe80:') ||
ip === 'unknown'
)
}
+809 -53
View File
File diff suppressed because it is too large Load Diff
+840 -133
View File
File diff suppressed because it is too large Load Diff
+872 -858
View File
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
import { useState } from 'react'
import type { ChangeEvent } from 'react'
import { useNavigate } from 'react-router-dom'
interface ContactFields {
firstName: string
lastName: string
email: string
message: string
messageType: string
}
export default function ContactForm() {
const navigate = useNavigate()
const [fields, setFields] = useState<ContactFields>({
firstName: '',
lastName: '',
email: '',
message: '',
messageType: 'question',
})
const [subscribe, setSubscribe] = useState(true)
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
function handleSelectChange(e: ChangeEvent<HTMLSelectElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus('submitting')
setErrorMsg('')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...fields, subscribe, _honey: honey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setErrorMsg((data as { message?: string }).message ?? 'Something went wrong. Please try again.')
setStatus('error')
return
}
navigate('/thanks')
} catch {
setErrorMsg('Could not connect. Please try again later.')
setStatus('error')
}
}
return (
<form className="contact-form" onSubmit={handleSubmit} noValidate>
<input
type="text"
className="contact-honeypot"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
value={honey}
onChange={e => setHoney(e.target.value)}
/>
<label>
First Name
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
</label>
<label>
Last Name
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
</label>
<label>
Email
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
</label>
<label>
Message Type
<select name="messageType" value={fields.messageType} onChange={handleSelectChange}>
<option value="question">Bible Question</option>
<option value="testimony">Testimony</option>
<option value="topic">Topic Request</option>
<option value="general">General Message</option>
</select>
</label>
<label>
Message
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
</label>
<label className="contact-consent">
<input
type="checkbox"
checked={subscribe}
onChange={e => setSubscribe(e.target.checked)}
/>
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Sending…' : 'Send Message'}
</button>
</form>
)
}
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useState } from 'react'
import type { KeyboardEvent } from 'react'
interface PublicQuestion {
id: string
firstName: string
question: string
answer: string
topic?: string
}
const QA_PAGE_SIZE = 6
function tokenizeForRelated(text: string) {
return text
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/)
.filter(token => token.length > 3)
}
export default function QASection() {
const [questions, setQuestions] = useState<PublicQuestion[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
const [expanded, setExpanded] = useState<{ [key: string]: boolean }>({})
const [page, setPage] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/questions')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
.then(data => {
setQuestions((data as { questions: PublicQuestion[] }).questions ?? [])
setLoading(false)
})
.catch(() => {
setLoading(false)
})
}, [])
const topics = Array.from(new Set(questions.map(q => q.topic).filter(Boolean))) as string[]
const filteredQuestions = questions.filter(q => {
const matchesTopic = !selectedTopic || q.topic === selectedTopic
const matchesSearch =
!searchQuery ||
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.answer.toLowerCase().includes(searchQuery.toLowerCase())
return matchesTopic && matchesSearch
})
const totalPages = Math.ceil(filteredQuestions.length / QA_PAGE_SIZE)
const pagedQuestions = filteredQuestions.slice(page * QA_PAGE_SIZE, (page + 1) * QA_PAGE_SIZE)
const toggleExpanded = (id: string) => {
setExpanded(state => ({ ...state, [id]: !state[id] }))
}
const handleSearch = (value: string) => {
setSearchQuery(value)
setPage(0)
}
const handleTopic = (topic: string | null) => {
setSelectedTopic(topic)
setPage(0)
}
const getRelatedQuestions = (current: PublicQuestion) => {
const currentTokens = new Set(tokenizeForRelated(`${current.question} ${current.answer}`))
return questions
.filter(candidate => candidate.id !== current.id)
.map(candidate => {
const candidateTokens = tokenizeForRelated(`${candidate.question} ${candidate.answer}`)
const overlap = candidateTokens.filter(token => currentTokens.has(token)).length
const sameTopic = Boolean(current.topic && candidate.topic && current.topic === candidate.topic)
const score = overlap + (sameTopic ? 5 : 0)
return { candidate, score }
})
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 3)
.map(item => item.candidate)
}
return (
<section id="qa" className="section-qa" aria-label="Questions and answers">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> Questions & Answers <span className="ornament"></span>
</h2>
{loading ? (
<p className="qa-no-results">Loading questions</p>
) : questions.length === 0 ? (
<div className="qa-no-results">
<p>No questions have been answered yet. <a href="#contact">Submit yours below!</a></p>
</div>
) : (
<>
<div className="qa-filters">
<div className="qa-topics">
<button
className={`qa-topic-btn${selectedTopic === null ? ' qa-topic-btn--active' : ''}`}
onClick={() => handleTopic(null)}
>
All
</button>
{topics.map(topic => (
<button
key={topic}
className={`qa-topic-btn${selectedTopic === topic ? ' qa-topic-btn--active' : ''}`}
onClick={() => handleTopic(topic)}
>
{topic}
</button>
))}
</div>
<div className="qa-search">
<label>
<span className="visually-hidden">Search Questions</span>
<input
type="text"
placeholder="Search questions…"
value={searchQuery}
onChange={e => handleSearch(e.target.value)}
/>
</label>
</div>
</div>
{filteredQuestions.length === 0 ? (
<div className="qa-no-results">
<p>No matching questions found. <a href="#contact">Submit your question</a></p>
</div>
) : (
<>
<div className="qa-cards">
{pagedQuestions.map(question => (
<div key={question.id} className="qa-card-scene">
<div
className={`qa-card-inner ${expanded[question.id] ? 'flipped' : ''}`}
onClick={() => toggleExpanded(question.id)}
role="button"
tabIndex={0}
aria-expanded={!!expanded[question.id]}
aria-label={question.question}
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') toggleExpanded(question.id)
}}
>
<div className="qa-card-face qa-card-front">
<span className="qa-face-label">Q</span>
<p className="qa-question-text">{question.question}</p>
<span className="qa-flip-hint">{expanded[question.id] ? '▲' : '▼'}</span>
</div>
<div className="qa-card-face qa-card-back">
<span className="qa-face-label">A</span>
<p className="qa-answer-text">{question.answer}</p>
{getRelatedQuestions(question).length > 0 && (
<div className="qa-related-wrap">
<p className="qa-related-label">Related questions</p>
<div className="qa-related-list">
{getRelatedQuestions(question).map(related => (
<button
key={related.id}
type="button"
className="qa-related-btn"
onClick={e => {
e.stopPropagation()
handleTopic(null)
handleSearch(related.question)
setExpanded({ [related.id]: true })
}}
>
{related.question}
</button>
))}
</div>
</div>
)}
<p style={{ margin: '0.75rem 0 0', fontSize: '0.8rem', color: '#a89060', fontStyle: 'italic' }}>
Answered by Nate
</p>
</div>
</div>
</div>
))}
</div>
{totalPages > 1 && (
<div className="qa-pagination">
<button
className="qa-page-btn"
onClick={() => setPage(p => p - 1)}
disabled={page === 0}
aria-label="Previous page"
>
Prev
</button>
<span className="qa-page-info">
{page + 1} / {totalPages}
</span>
<button
className="qa-page-btn"
onClick={() => setPage(p => p + 1)}
disabled={page >= totalPages - 1}
aria-label="Next page"
>
Next
</button>
</div>
)}
</>
)}
</>
)}
</div>
</section>
)
}
+206
View File
@@ -0,0 +1,206 @@
export interface CustomLink {
id: string
label: string
url: string
placement: 'platforms' | 'footer' | 'resources'
imageUrl?: string
tags?: string[]
}
export interface CustomBlock {
id: string
heading: string
body: string
}
export interface ArchivedSeriesResourceLink {
id: string
label: string
url: string
}
export interface ArchivedSeriesNote {
id: string
heading: string
body: string
}
export interface ArchivedSeries {
id: string
label: string
title: string
description: string
imageUrl: string
listenUrl: string
studyGuideTitle: string
studyGuideDescription: string
studyGuideUrl: string
resourceLinks: ArchivedSeriesResourceLink[]
notes: ArchivedSeriesNote[]
}
export interface RedirectRule {
id: string
path: string
target: string
statusCode: 301 | 302
}
export interface PodcastFeaturedLink {
id: string
title: string
episodeNumber?: string
summary: string
url: string
embedUrl?: string
showNotes?: string
discussionQuestions?: string[]
}
export interface SeoSettings {
title: string
description: string
ogTitle: string
ogDescription: string
ogImage: string
canonicalUrl: string
robotsPolicy: string
sitemapPaths: string[]
}
export interface LegalSettings {
privacyTitle: string
privacyBody: string[]
termsTitle: string
termsBody: string[]
}
export interface SiteContent {
eyebrow: string
heroTagline: string
startHereHeading: string
startHereIntro: string
startHereStep1Title: string
startHereStep1Body: string
startHereStep1Cta: string
startHereStep2Title: string
startHereStep2Body: string
startHereStep2Cta: string
startHereStep3Title: string
startHereStep3Body: string
startHereStep3Cta: string
aboutShowHeading: string
aboutShowP1: string
aboutShowP2: string
aboutNate: string
aboutPhotoUrl: string
aboutVerseArtUrl: string
contactPhotoUrl: string
seriesLabel: string
seriesTitle: string
seriesDescription: string
seriesImageUrl: string
seriesListenUrl: string
studyGuideTitle: string
studyGuideDescription: string
studyGuideUrl: string
shareHeading: string
shareP: string
customLinks: CustomLink[]
customBlocks: CustomBlock[]
archivedSeries: ArchivedSeries[]
redirects: RedirectRule[]
podcastFeaturedLinks: PodcastFeaturedLink[]
seo: SeoSettings
legal: LegalSettings
}
export const DEFAULTS: SiteContent = {
eyebrow: 'A Journey Through Scripture',
heroTagline: "Exploring God's Word one verse at a time",
startHereHeading: 'New Here? Start Here',
startHereIntro:
'If this is your first visit, this path helps you get grounded quickly and make the most of the site.',
startHereStep1Title: 'Step 1: Listen to 3 Starter Episodes',
startHereStep1Body:
'Start with the newest episode, one from the beginning of the current study, and one from the middle.',
startHereStep1Cta: 'Open Episodes',
startHereStep2Title: 'Step 2: Use Q&A to Go Deeper',
startHereStep2Body:
'Browse by topic or search key words to find concise biblical answers and related follow-up questions.',
startHereStep2Cta: 'Go to Q&A',
startHereStep3Title: 'Step 3: Ask Nate Directly',
startHereStep3Body:
'Use the contact form to submit your Bible question for future Q&A or an upcoming episode.',
startHereStep3Cta: 'Submit a Question',
aboutShowHeading: 'Depth. Clarity. Application.',
aboutShowP1:
'Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.',
aboutShowP2:
"Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
aboutNate:
"Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
aboutPhotoUrl: '/images/nate-photo.jpeg',
aboutVerseArtUrl: '/images/hebrews-4-12-verse-art.png',
contactPhotoUrl: '/images/nate-contact-photo.png',
seriesLabel: 'Now Playing',
seriesTitle: 'Study of Titus: Sound Doctrine',
seriesDescription:
"A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
seriesImageUrl: '/images/titus-cover.png',
seriesListenUrl: '/spotify',
studyGuideTitle: 'Companion Study Guide',
studyGuideDescription:
'Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.',
studyGuideUrl: 'https://a.co/d/01sG2tOJ',
shareHeading: 'Help one more person hear the Word this week.',
shareP: 'Scan the QR code or text the show link to a friend who needs encouragement today.',
customLinks: [],
customBlocks: [],
archivedSeries: [],
redirects: [
{
id: 'spotify',
path: '/spotify',
target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
statusCode: 301,
},
{
id: 'apple',
path: '/apple',
target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
statusCode: 301,
},
{
id: 'amazon',
path: '/amazon',
target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
statusCode: 301,
},
],
podcastFeaturedLinks: [],
seo: {
title: 'Verse by Verse with Nate',
description: "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
ogTitle: 'Verse by Verse with Nate',
ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.',
ogImage: '/images/podcast-art.jpeg',
canonicalUrl: 'https://versebyversewithnate.us/',
robotsPolicy: 'index,follow',
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'],
},
legal: {
privacyTitle: 'Privacy Policy',
privacyBody: [
'We respect your privacy and collect limited data to operate and improve this site.',
'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
'Contact form details are used only to respond to your message and ministry communication requests.',
],
termsTitle: 'Terms',
termsBody: [
'Content on this site is for informational and ministry purposes.',
'External links are provided for convenience and are subject to third-party policies.',
'By using this site, you agree to lawful use and respectful communication.',
],
},
}
+31
View File
@@ -0,0 +1,31 @@
export function FacebookIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.234 2.686.234v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
</svg>
)
}
export function SpotifyIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
</svg>
)
}
export function YouTubeIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
)
}
export function AmazonMusicIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</svg>
)
}