Merge pull request #4 from nmemmert/feature/online-class-upgrade
feat: homepage newsletter, Q&A callout, episodes page redesign, SEO/s…
This commit is contained in:
@@ -1144,6 +1144,7 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"episodesSeoIntro": "",
|
||||||
"seo": {
|
"seo": {
|
||||||
"title": "Verse by Verse with Nate",
|
"title": "Verse by Verse with Nate",
|
||||||
"description": "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
|
"description": "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
|
||||||
@@ -1175,5 +1176,5 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"updatedAt": "2026-06-02T17:54:05.948Z"
|
"updatedAt": "2026-06-03T14:17:48.071Z"
|
||||||
}
|
}
|
||||||
@@ -1144,6 +1144,7 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"episodesSeoIntro": "",
|
||||||
"seo": {
|
"seo": {
|
||||||
"title": "Verse by Verse with Nate",
|
"title": "Verse by Verse with Nate",
|
||||||
"description": "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
|
"description": "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
|
||||||
@@ -1175,5 +1176,5 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"updatedAt": "2026-06-02T17:54:06.010Z"
|
"updatedAt": "2026-06-03T14:17:48.087Z"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -98,5 +98,5 @@
|
|||||||
"topic": "Faith & Life"
|
"topic": "Faith & Life"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"updatedAt": "2026-06-02T17:54:06.010Z"
|
"updatedAt": "2026-06-03T14:17:48.087Z"
|
||||||
}
|
}
|
||||||
+15
-7
@@ -6,8 +6,11 @@
|
|||||||
"passwordHash": "6500cc2e717d1d646f371dba1ec918d2f2f9fddbb8576c792df69a1f2340dcd9",
|
"passwordHash": "6500cc2e717d1d646f371dba1ec918d2f2f9fddbb8576c792df69a1f2340dcd9",
|
||||||
"displayName": "Student Test",
|
"displayName": "Student Test",
|
||||||
"subscribeNewsletter": false,
|
"subscribeNewsletter": false,
|
||||||
|
"studyRemindersEnabled": false,
|
||||||
|
"enrolledStudySlugs": [],
|
||||||
"avatarUrl": "",
|
"avatarUrl": "",
|
||||||
"lastLoginAt": "2026-05-21T15:41:16.259Z"
|
"lastLoginAt": "2026-05-21T15:41:16.259Z",
|
||||||
|
"pendingEmailChange": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
|
"id": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
|
||||||
@@ -15,12 +18,14 @@
|
|||||||
"passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5",
|
"passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5",
|
||||||
"displayName": "Test Student",
|
"displayName": "Test Student",
|
||||||
"subscribeNewsletter": false,
|
"subscribeNewsletter": false,
|
||||||
"avatarUrl": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2288%22%20height%3D%2288%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20rx%3D%2218%22%20ry%3D%2218%22%20fill%3D%22%2311100d%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2250%25%22%20dominant-baseline%3D%22middle%22%20text-anchor%3D%22middle%22%20font-size%3D%2256%22%3E%F0%9F%A6%81%3C%2Ftext%3E%3C%2Fsvg%3E",
|
"studyRemindersEnabled": false,
|
||||||
"lastLoginAt": "2026-06-02T17:56:40.769Z",
|
|
||||||
"updatedAt": "2026-06-02T17:57:04.582Z",
|
|
||||||
"enrolledStudySlugs": [
|
"enrolledStudySlugs": [
|
||||||
"colossians"
|
"colossians"
|
||||||
]
|
],
|
||||||
|
"avatarUrl": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2288%22%20height%3D%2288%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20rx%3D%2218%22%20ry%3D%2218%22%20fill%3D%22%2311100d%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2250%25%22%20dominant-baseline%3D%22middle%22%20text-anchor%3D%22middle%22%20font-size%3D%2256%22%3E%F0%9F%A6%81%3C%2Ftext%3E%3C%2Fsvg%3E",
|
||||||
|
"lastLoginAt": "2026-06-03T14:16:38.510Z",
|
||||||
|
"pendingEmailChange": null,
|
||||||
|
"updatedAt": "2026-06-03T14:16:38.510Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "81c01e32-d94e-4e19-93ea-e5cd7644ea6a",
|
"id": "81c01e32-d94e-4e19-93ea-e5cd7644ea6a",
|
||||||
@@ -28,9 +33,12 @@
|
|||||||
"passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5",
|
"passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5",
|
||||||
"displayName": "Avatar Test",
|
"displayName": "Avatar Test",
|
||||||
"subscribeNewsletter": false,
|
"subscribeNewsletter": false,
|
||||||
|
"studyRemindersEnabled": false,
|
||||||
|
"enrolledStudySlugs": [],
|
||||||
"avatarUrl": "data:image/svg+xml;utf8,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"88\" height=\"88\"%3E%3Crect width=\"100%\" height=\"100%\" rx=\"18\" ry=\"18\" fill=\"%2311100d\"/%3E%3Ctext x=\"50%\" y=\"50%\" dominant-baseline=\"middle\" text-anchor=\"middle\" font-size=\"56\"%3E🐱%3C/text%3E%3C/svg%3E",
|
"avatarUrl": "data:image/svg+xml;utf8,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"88\" height=\"88\"%3E%3Crect width=\"100%\" height=\"100%\" rx=\"18\" ry=\"18\" fill=\"%2311100d\"/%3E%3Ctext x=\"50%\" y=\"50%\" dominant-baseline=\"middle\" text-anchor=\"middle\" font-size=\"56\"%3E🐱%3C/text%3E%3C/svg%3E",
|
||||||
"lastLoginAt": "2026-06-02T17:33:13.669Z"
|
"lastLoginAt": "2026-06-02T17:33:13.669Z",
|
||||||
|
"pendingEmailChange": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"updatedAt": "2026-06-02T17:57:04.583Z"
|
"updatedAt": "2026-06-03T14:16:38.513Z"
|
||||||
}
|
}
|
||||||
@@ -3342,7 +3342,7 @@ app.post('/api/study-community/posts', requireStudyAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const authorName = user.displayName?.trim() || user.username
|
const authorName = user.displayName?.trim() || (user.username?.includes('@') ? user.username.split('@')[0] : user.username)
|
||||||
const post = {
|
const post = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
studySlug,
|
studySlug,
|
||||||
@@ -3386,7 +3386,7 @@ app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async (
|
|||||||
const reply = {
|
const reply = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
authorUserId: user.id,
|
authorUserId: user.id,
|
||||||
authorName: user.displayName?.trim() || user.username,
|
authorName: user.displayName?.trim() || (user.username?.includes('@') ? user.username.split('@')[0] : user.username),
|
||||||
authorAvatarUrl: getStudyAvatarUrl(user.username),
|
authorAvatarUrl: getStudyAvatarUrl(user.username),
|
||||||
message,
|
message,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
|||||||
+2
-1
@@ -29,7 +29,7 @@ export const DEFAULT_SEO = {
|
|||||||
ogImage: '/images/podcast-art.jpeg',
|
ogImage: '/images/podcast-art.jpeg',
|
||||||
canonicalUrl: 'https://versebyversewithnate.us/',
|
canonicalUrl: 'https://versebyversewithnate.us/',
|
||||||
robotsPolicy: 'index,follow',
|
robotsPolicy: 'index,follow',
|
||||||
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'],
|
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms', '/about', '/contact', '/episodes', '/resources', '/study', '/study/titus', '/study/colossians'],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_LEGAL = {
|
export const DEFAULT_LEGAL = {
|
||||||
@@ -338,6 +338,7 @@ export function sanitizeSiteContent(siteContent) {
|
|||||||
colossiansStudySections: sanitizeColossiansStudySections(siteContent.colossiansStudySections),
|
colossiansStudySections: sanitizeColossiansStudySections(siteContent.colossiansStudySections),
|
||||||
redirects: sanitizeRedirectRules(siteContent.redirects),
|
redirects: sanitizeRedirectRules(siteContent.redirects),
|
||||||
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
|
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
|
||||||
|
episodesSeoIntro: typeof siteContent.episodesSeoIntro === 'string' && siteContent.episodesSeoIntro.trim() ? siteContent.episodesSeoIntro.trim().slice(0, 600) : '',
|
||||||
seo: {
|
seo: {
|
||||||
title: typeof seo.title === 'string' && seo.title.trim() ? seo.title.trim().slice(0, 120) : DEFAULT_SEO.title,
|
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,
|
description: typeof seo.description === 'string' && seo.description.trim() ? seo.description.trim().slice(0, 240) : DEFAULT_SEO.description,
|
||||||
|
|||||||
@@ -491,6 +491,7 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect
|
|||||||
{ key: 'contactPoint2', label: 'Contact — Point 2', section: 'contact' },
|
{ key: 'contactPoint2', label: 'Contact — Point 2', section: 'contact' },
|
||||||
{ key: 'contactVerse', label: 'Contact — Scripture Text', section: 'contact' },
|
{ key: 'contactVerse', label: 'Contact — Scripture Text', section: 'contact' },
|
||||||
{ key: 'contactVerseRef', label: 'Contact — Scripture Reference', section: 'contact' },
|
{ key: 'contactVerseRef', label: 'Contact — Scripture Reference', section: 'contact' },
|
||||||
|
{ key: 'episodesSeoIntro', label: 'Episodes Page — SEO Description (hidden from visitors, indexed by search engines)', multiline: true, section: 'series' },
|
||||||
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")', section: 'series' },
|
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")', section: 'series' },
|
||||||
{ key: 'seriesTitle', label: 'Series Title', section: 'series' },
|
{ key: 'seriesTitle', label: 'Series Title', section: 'series' },
|
||||||
{ key: 'seriesDescription', label: 'Series Description', multiline: true, section: 'series' },
|
{ key: 'seriesDescription', label: 'Series Description', multiline: true, section: 'series' },
|
||||||
|
|||||||
+176
@@ -403,6 +403,62 @@
|
|||||||
padding: 5rem 0;
|
padding: 5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.episode-seo-intro {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-episode-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2.5rem;
|
||||||
|
align-items: center;
|
||||||
|
background: #111110;
|
||||||
|
border: 1px solid rgba(201, 168, 76, 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-episode-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f0e9cc;
|
||||||
|
margin: 0.5rem 0 0.75rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-episode-pills {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-episode-desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #8a7f5a;
|
||||||
|
line-height: 1.6;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-episode-embed {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.latest-episode-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.episode-series-subtitle {
|
.episode-series-subtitle {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--brand-gold);
|
color: var(--brand-gold);
|
||||||
@@ -735,6 +791,71 @@
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Homepage Newsletter ── */
|
||||||
|
.section-newsletter {
|
||||||
|
background: #0c0c08;
|
||||||
|
border-top: 1px solid rgba(201, 168, 76, 0.15);
|
||||||
|
border-bottom: 1px solid rgba(201, 168, 76, 0.15);
|
||||||
|
padding: 4rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-inner {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-sub {
|
||||||
|
color: #a89a6a;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-fields {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-input {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(201, 168, 76, 0.3);
|
||||||
|
background: #1a1a12;
|
||||||
|
color: #f0e9cc;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-input::placeholder {
|
||||||
|
color: #5a5440;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-input:focus {
|
||||||
|
border-color: rgba(201, 168, 76, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-thanks {
|
||||||
|
color: #c9a84c;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-error {
|
||||||
|
color: #e05c5c;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Series ── */
|
/* ── Series ── */
|
||||||
.section-series {
|
.section-series {
|
||||||
background: #080808;
|
background: #080808;
|
||||||
@@ -935,6 +1056,61 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Homepage Q&A Callout ── */
|
||||||
|
.section-qa-callout {
|
||||||
|
background: #080808;
|
||||||
|
border-top: 1px solid rgba(201, 168, 76, 0.18);
|
||||||
|
border-bottom: 1px solid rgba(201, 168, 76, 0.18);
|
||||||
|
padding: 5rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-callout-sub {
|
||||||
|
color: #8a7f5a;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: -0.5rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-callout-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-callout-card {
|
||||||
|
background: #111110;
|
||||||
|
border: 1px solid rgba(201, 168, 76, 0.15);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-callout-question {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #c9a84c;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-callout-answer {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #a89a6a;
|
||||||
|
line-height: 1.65;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-callout-from {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #5a5440;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Home Jump Section ── */
|
/* ── Home Jump Section ── */
|
||||||
.section-share {
|
.section-share {
|
||||||
background: #080808;
|
background: #080808;
|
||||||
|
|||||||
+145
-1
@@ -978,6 +978,107 @@ function ExternalSitesSection({ content }: { content: SiteContent }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HomepageNewsletterSection() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [firstName, setFirstName] = useState('')
|
||||||
|
const [honey, setHoney] = useState('')
|
||||||
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle')
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (honey) return
|
||||||
|
setStatus('submitting')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ firstName, email, messageType: 'general', message: 'Newsletter signup from homepage', subscribe: true, _honey: honey }),
|
||||||
|
})
|
||||||
|
setStatus(res.ok ? 'done' : 'error')
|
||||||
|
} catch {
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section-newsletter" aria-label="Newsletter sign-up">
|
||||||
|
<div className="section-inner newsletter-inner">
|
||||||
|
<p className="eyebrow">Stay in the Word</p>
|
||||||
|
<h2 className="section-heading">Get episode updates by email</h2>
|
||||||
|
<p className="newsletter-sub">New episodes, study resources, and ministry updates — delivered to your inbox.</p>
|
||||||
|
{status === 'done' ? (
|
||||||
|
<p className="newsletter-thanks">You're subscribed! Thank you for signing up.</p>
|
||||||
|
) : (
|
||||||
|
<form className="newsletter-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)} />
|
||||||
|
<div className="newsletter-fields">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="First name"
|
||||||
|
required
|
||||||
|
autoComplete="given-name"
|
||||||
|
value={firstName}
|
||||||
|
onChange={e => setFirstName(e.target.value)}
|
||||||
|
className="newsletter-input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email address"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
className="newsletter-input"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
||||||
|
{status === 'submitting' ? 'Subscribing…' : 'Subscribe'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{status === 'error' && <p className="newsletter-error">Something went wrong. Please try again.</p>}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomepageQACallout() {
|
||||||
|
const [questions, setQuestions] = useState<{ id: string; firstName: string; question: string; answer: string }[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/questions')
|
||||||
|
.then(r => (r.ok ? r.json() : Promise.reject()))
|
||||||
|
.then((data: { questions: { id: string; firstName: string; question: string; answer: string }[] }) => {
|
||||||
|
setQuestions((data.questions ?? []).slice(0, 3))
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (questions.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section-qa-callout" aria-label="Featured Q&A">
|
||||||
|
<div className="section-inner">
|
||||||
|
<p className="eyebrow">Q&A</p>
|
||||||
|
<h2 className="section-heading"><span className="ornament">✦</span> Questions from Listeners <span className="ornament">✦</span></h2>
|
||||||
|
<p className="qa-callout-sub">Real questions. Scripture-grounded answers.</p>
|
||||||
|
<div className="qa-callout-grid">
|
||||||
|
{questions.map(q => (
|
||||||
|
<div key={q.id} className="qa-callout-card">
|
||||||
|
<p className="qa-callout-question">"{q.question}"</p>
|
||||||
|
<p className="qa-callout-answer">{q.answer.length > 220 ? q.answer.slice(0, 220).trimEnd() + '…' : q.answer}</p>
|
||||||
|
{q.firstName && <p className="qa-callout-from">— {q.firstName}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', marginTop: '2rem' }}>
|
||||||
|
<Link to="/questions" className="btn-primary">Browse All Q&A →</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function LandingPage({ content }: { content: SiteContent }) {
|
function LandingPage({ content }: { content: SiteContent }) {
|
||||||
const featuredStudy = getHomepageFeaturedStudy(content)
|
const featuredStudy = getHomepageFeaturedStudy(content)
|
||||||
|
|
||||||
@@ -1063,6 +1164,8 @@ function LandingPage({ content }: { content: SiteContent }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<HomepageNewsletterSection />
|
||||||
|
|
||||||
<section className="section-series" id="series" aria-label="Current series">
|
<section className="section-series" id="series" aria-label="Current series">
|
||||||
<div className="section-inner">
|
<div className="section-inner">
|
||||||
<h2 className="section-heading">
|
<h2 className="section-heading">
|
||||||
@@ -1125,6 +1228,8 @@ function LandingPage({ content }: { content: SiteContent }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<HomepageQACallout />
|
||||||
|
|
||||||
<section className="section-share" aria-label="Share the show">
|
<section className="section-share" aria-label="Share the show">
|
||||||
<div className="section-inner section-share-inner">
|
<div className="section-inner section-share-inner">
|
||||||
<div className="share-text">
|
<div className="share-text">
|
||||||
@@ -1386,12 +1491,51 @@ function EpisodesPage({ content }: { content: SiteContent }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const latestEpisode = !loading && !failed ? (currentEpisodes[0] ?? allEpisodes[0]) : null
|
||||||
|
const latestEmbedUrl = latestEpisode ? toSpotifyEpisodeEmbedUrl(latestEpisode.link) : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="site">
|
<div className="site">
|
||||||
<SiteHeader content={content} />
|
<SiteHeader content={content} />
|
||||||
|
|
||||||
<section className="section-player" aria-label="Current series episodes">
|
<section className="section-player" aria-label="Current series episodes">
|
||||||
<div className="section-inner">
|
<div className="section-inner">
|
||||||
<h2 className="section-heading">
|
<p className="episode-seo-intro" aria-hidden="true">
|
||||||
|
{content.episodesSeoIntro || 'Verse by Verse with Nate is an expository Bible teaching podcast where we go through Scripture one verse at a time. Each episode digs into the text carefully and practically, helping you understand what the Bible says, what it means, and how to live it out. New episodes released regularly — subscribe on Spotify or your favorite podcast platform.'}
|
||||||
|
</p>
|
||||||
|
{latestEpisode && (
|
||||||
|
<div className="latest-episode-card">
|
||||||
|
<div className="latest-episode-meta">
|
||||||
|
<p className="eyebrow">Latest Episode</p>
|
||||||
|
<h2 className="latest-episode-title">{latestEpisode.title}</h2>
|
||||||
|
<div className="latest-episode-pills">
|
||||||
|
{latestEpisode.episode && <span className="episode-number">Ep. {latestEpisode.episode}</span>}
|
||||||
|
{latestEpisode.pubDate && <span className="episode-date">{formatPubDate(latestEpisode.pubDate)}</span>}
|
||||||
|
{latestEpisode.duration && <span className="episode-duration">{latestEpisode.duration}</span>}
|
||||||
|
</div>
|
||||||
|
{latestEpisode.description && <p className="latest-episode-desc">{latestEpisode.description}</p>}
|
||||||
|
<a href={latestEpisode.link || content.platformSpotifyUrl || '/spotify'} target="_blank" rel="noreferrer" className="btn-primary" style={{ marginTop: '1rem', display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||||
|
<SpotifyIcon /> Listen on Spotify
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{latestEmbedUrl && (
|
||||||
|
<div className="latest-episode-embed">
|
||||||
|
<iframe
|
||||||
|
title={latestEpisode.title}
|
||||||
|
src={latestEmbedUrl}
|
||||||
|
width="100%"
|
||||||
|
height="232"
|
||||||
|
frameBorder="0"
|
||||||
|
allowFullScreen
|
||||||
|
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||||
|
loading="lazy"
|
||||||
|
style={{ borderRadius: '12px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h2 className="section-heading" style={{ marginTop: latestEpisode ? '3rem' : undefined }}>
|
||||||
<span className="ornament">✦</span> {content.seriesLabel || 'Now Playing'}{' '}
|
<span className="ornament">✦</span> {content.seriesLabel || 'Now Playing'}{' '}
|
||||||
<span className="ornament">✦</span>
|
<span className="ornament">✦</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
+33
-1
@@ -463,6 +463,8 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
const [studyModal, setStudyModal] = useState<'none' | 'enrollments' | 'browseTracks'>('none')
|
const [studyModal, setStudyModal] = useState<'none' | 'enrollments' | 'browseTracks'>('none')
|
||||||
const [enrollBusySlug, setEnrollBusySlug] = useState('')
|
const [enrollBusySlug, setEnrollBusySlug] = useState('')
|
||||||
const [enrollMessage, setEnrollMessage] = useState('')
|
const [enrollMessage, setEnrollMessage] = useState('')
|
||||||
|
const [showNewsletterNudge, setShowNewsletterNudge] = useState(false)
|
||||||
|
const [newsletterNudgeDone, setNewsletterNudgeDone] = useState(false)
|
||||||
|
|
||||||
const studies = getStudies(content)
|
const studies = getStudies(content)
|
||||||
const activeStudies = studies.filter(study => study.status !== 'planned')
|
const activeStudies = studies.filter(study => study.status !== 'planned')
|
||||||
@@ -495,6 +497,7 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
const slugs = normalizeEnrolledStudySlugs(data.enrolledStudySlugs)
|
const slugs = normalizeEnrolledStudySlugs(data.enrolledStudySlugs)
|
||||||
setAuth(prev => ({ ...prev, enrolledStudySlugs: slugs }))
|
setAuth(prev => ({ ...prev, enrolledStudySlugs: slugs }))
|
||||||
setEnrollMessage(enrolled ? `Unenrolled from ${data.studyTitle ?? studySlug}.` : `Enrolled in ${data.studyTitle ?? studySlug}.`)
|
setEnrollMessage(enrolled ? `Unenrolled from ${data.studyTitle ?? studySlug}.` : `Enrolled in ${data.studyTitle ?? studySlug}.`)
|
||||||
|
if (!enrolled && !auth.subscribeNewsletter) setShowNewsletterNudge(true)
|
||||||
await refreshOverview()
|
await refreshOverview()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setEnrollMessage(err instanceof Error ? err.message : 'Unable to update enrollment.')
|
setEnrollMessage(err instanceof Error ? err.message : 'Unable to update enrollment.')
|
||||||
@@ -678,6 +681,22 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{enrollMessage && <p className="study-note-status" style={{ marginTop: '1rem' }}>{enrollMessage}</p>}
|
{enrollMessage && <p className="study-note-status" style={{ marginTop: '1rem' }}>{enrollMessage}</p>}
|
||||||
|
{showNewsletterNudge && !newsletterNudgeDone && (
|
||||||
|
<div className="study-newsletter-nudge" style={{ marginTop: '1rem', background: '#1a1a12', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '8px', padding: '1rem', fontSize: '0.9rem' }}>
|
||||||
|
<p style={{ margin: '0 0 0.5rem' }}>Want episode updates and study announcements by email?</p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
|
<button type="button" className="btn-primary" style={{ fontSize: '0.85rem', padding: '0.4rem 1rem' }} onClick={async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/study-account/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subscribeNewsletter: true }) })
|
||||||
|
setAuth(prev => ({ ...prev, subscribeNewsletter: true }))
|
||||||
|
} catch {}
|
||||||
|
setNewsletterNudgeDone(true)
|
||||||
|
setShowNewsletterNudge(false)
|
||||||
|
}}>Yes, subscribe me</button>
|
||||||
|
<button type="button" className="btn-secondary" style={{ fontSize: '0.85rem', padding: '0.4rem 1rem' }} onClick={() => setShowNewsletterNudge(false)}>No thanks</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -844,6 +863,19 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<section className="section-study-module" aria-label="Q&A">
|
||||||
|
<div className="section-inner" style={{ textAlign: 'center', padding: '3rem 1rem' }}>
|
||||||
|
<p className="eyebrow">Have a question from the Word?</p>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '0.5rem 0 0.75rem', color: '#f0e9cc' }}>Ask Nate a Question</h2>
|
||||||
|
<p style={{ color: '#8a7f5a', maxWidth: '480px', margin: '0 auto 1.5rem', fontSize: '0.95rem' }}>
|
||||||
|
Submit a Bible or theology question and it may be answered in a future episode or the Q&A archive.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<a href="/questions" className="btn-primary">Browse Q&A Archive →</a>
|
||||||
|
<a href="/questions#ask" className="btn-secondary">Submit a Question</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -2299,7 +2331,7 @@ export function StudyAccountPage() {
|
|||||||
<p style={{ margin: 0, fontSize: '0.85rem' }}>Total notes saved</p>
|
<p style={{ margin: 0, fontSize: '0.85rem' }}>Total notes saved</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: '1 1 150px', background: '#1a1a16', borderRadius: '8px', padding: '1rem', border: '1px solid #2a2518' }}>
|
<div style={{ flex: '1 1 150px', background: '#1a1a16', borderRadius: '8px', padding: '1rem', border: '1px solid #2a2518' }}>
|
||||||
<p style={{ fontSize: '1rem', fontWeight: 600, margin: 0 }}>{new Date(overview.stats.memberSince).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</p>
|
<p style={{ fontSize: '1rem', fontWeight: 600, margin: 0 }}>{overview.stats.memberSince && !isNaN(new Date(overview.stats.memberSince).getTime()) ? new Date(overview.stats.memberSince).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</p>
|
||||||
<p style={{ margin: 0, fontSize: '0.85rem' }}>Member since</p>
|
<p style={{ margin: 0, fontSize: '0.85rem' }}>Member since</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ export interface SiteContent {
|
|||||||
podcastFeaturedLinks: PodcastFeaturedLink[]
|
podcastFeaturedLinks: PodcastFeaturedLink[]
|
||||||
seo: SeoSettings
|
seo: SeoSettings
|
||||||
legal: LegalSettings
|
legal: LegalSettings
|
||||||
|
// ── Episodes page ──
|
||||||
|
episodesSeoIntro: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULTS: SiteContent = {
|
export const DEFAULTS: SiteContent = {
|
||||||
@@ -411,6 +413,7 @@ export const DEFAULTS: SiteContent = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
podcastFeaturedLinks: [],
|
podcastFeaturedLinks: [],
|
||||||
|
episodesSeoIntro: 'Verse by Verse with Nate is an expository Bible teaching podcast where we go through Scripture one verse at a time. Each episode digs into the text carefully and practically, helping you understand what the Bible says, what it means, and how to live it out. New episodes released regularly — subscribe on Spotify or your favorite podcast platform.',
|
||||||
seo: {
|
seo: {
|
||||||
title: 'Verse by Verse with Nate',
|
title: 'Verse by Verse with Nate',
|
||||||
description: "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
|
description: "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
|
||||||
|
|||||||
Reference in New Issue
Block a user