2 Commits

Author SHA1 Message Date
nmemmert 0c328057c8 Polish hero UX and add admin brand kit panel 2026-04-16 10:31:35 -04:00
nmemmert 5562db992b Ignore generated runtime data files 2026-04-16 08:37:32 -04:00
6 changed files with 1131 additions and 446 deletions
+3
View File
@@ -18,6 +18,9 @@ dist-ssr
# Uncomment the line below if you do NOT want to track live data in git. # Uncomment the line below if you do NOT want to track live data in git.
# data/admin-content.json # data/admin-content.json
data/backups/ data/backups/
data/hit-stats.json
data/visitor-stats.json
data/contact-submissions.json
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
+1 -1
View File
@@ -21,7 +21,7 @@
<link rel="canonical" href="https://necloud.us" /> <link rel="canonical" href="https://necloud.us" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;0,900;1,700&family=Barlow+Condensed:wght@300;400;500;700&display=swap" rel="stylesheet" /> <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" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+262 -45
View File
@@ -73,30 +73,108 @@ interface Question {
} }
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries'> type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries'>
const FIELDS: { key: StringField; label: string; multiline?: boolean }[] = [ type MainContentSection = 'hero' | 'start-here' | 'about' | 'series' | 'share'
{ key: 'eyebrow', label: 'Hero Eyebrow Text' },
{ key: 'heroTagline', label: 'Hero Tagline' }, const MAIN_CONTENT_SECTIONS: Array<{ id: MainContentSection; title: string; description: string }> = [
{ key: 'aboutShowHeading', label: 'About Show — Heading' }, {
{ key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true }, id: 'hero',
{ key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true }, title: 'Hero Section',
{ key: 'aboutNate', label: 'About Nate', multiline: true }, description: 'Controls the top intro copy on the homepage.',
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")' }, },
{ key: 'seriesTitle', label: 'Series Title' }, {
{ key: 'seriesDescription', label: 'Series Description', multiline: true }, id: 'start-here',
{ key: 'seriesImageUrl', label: 'Series Cover Image URL' }, title: 'Start Here Page',
{ key: 'seriesListenUrl', label: 'Series Listen URL' }, description: 'Edit the separate Start Here page heading, intro, and step cards.',
{ key: 'studyGuideTitle', label: 'Study Guide Title' }, },
{ key: 'studyGuideDescription', label: 'Study Guide Description', multiline: true }, {
{ key: 'studyGuideUrl', label: 'Study Guide URL (Amazon link)' }, id: 'about',
{ key: 'shareHeading', label: 'Share Section — Heading' }, title: 'About Section',
{ key: 'shareP', label: 'Share Section — Paragraph', multiline: true }, description: 'Manage the main show description and Nate bio content.',
},
{
id: 'series',
title: 'Current Series',
description: 'Update the active study card and companion guide content.',
},
{
id: 'share',
title: 'Share Section',
description: 'Controls the copy shown near the QR/share area.',
},
]
const BRAND_KIT_SWATCHES = [
{ name: 'Rich Black', hex: '#0a0a08', role: 'Primary background' },
{ name: 'Soft Black', hex: '#0f0f0c', role: 'Secondary surfaces' },
{ name: 'Panel Black', hex: '#1a1a15', role: 'Cards and panels' },
{ name: 'Deep Brown', hex: '#2a2518', role: 'Borders and dividers' },
{ name: 'Antique Gold', hex: '#c9a84c', role: 'Primary accent' },
{ name: 'Light Gold', hex: '#e0c070', role: 'Hover and highlight' },
{ name: 'Warm White', hex: '#f0ead8', role: 'Primary text' },
{ name: 'Warm Gray', hex: '#7a7060', role: 'Secondary text' },
] as const
const BRAND_KIT_TYPE = [
{
label: 'Brand Font',
spec: 'Cormorant Garamond · 300/400/600/700 · italic supported',
sample: 'Verse by Verse with Nate',
},
{
label: 'Subtitle / Small Caps',
spec: 'Uppercase with tracking for labels, nav, and support text',
sample: 'A Journey Through Scripture',
},
{
label: 'Body / Quote',
spec: 'Cormorant Garamond light/italic for long-form text and pull copy',
sample: 'Verse by verse. Nugget by nugget.',
},
] as const
const BRAND_KIT_VERIFICATION = [
'Global font import updated to Cormorant Garamond.',
'Shared brand variables now define black, gold, border, warm white, and muted text colors.',
'Live site CSS now references the brand variables for core text and accent styling.',
'Admin surfaces inherit the same typography and palette tokens used by the public site.',
] as const
const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; section: MainContentSection }> = [
{ key: 'eyebrow', label: 'Hero Eyebrow Text', section: 'hero' },
{ key: 'heroTagline', label: 'Hero Tagline', section: 'hero' },
{ key: 'startHereHeading', label: 'Start Here — Heading', section: 'start-here' },
{ key: 'startHereIntro', label: 'Start Here — Intro', multiline: true, section: 'start-here' },
{ key: 'startHereStep1Title', label: 'Start Here — Step 1 Title', section: 'start-here' },
{ key: 'startHereStep1Body', label: 'Start Here — Step 1 Body', multiline: true, section: 'start-here' },
{ key: 'startHereStep1Cta', label: 'Start Here — Step 1 Button Text', section: 'start-here' },
{ key: 'startHereStep2Title', label: 'Start Here — Step 2 Title', section: 'start-here' },
{ key: 'startHereStep2Body', label: 'Start Here — Step 2 Body', multiline: true, section: 'start-here' },
{ key: 'startHereStep2Cta', label: 'Start Here — Step 2 Button Text', section: 'start-here' },
{ key: 'startHereStep3Title', label: 'Start Here — Step 3 Title', section: 'start-here' },
{ key: 'startHereStep3Body', label: 'Start Here — Step 3 Body', multiline: true, section: 'start-here' },
{ key: 'startHereStep3Cta', label: 'Start Here — Step 3 Button Text', section: 'start-here' },
{ key: 'aboutShowHeading', label: 'About Show — Heading', section: 'about' },
{ key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true, section: 'about' },
{ key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true, section: 'about' },
{ key: 'aboutNate', label: 'About Nate', multiline: true, section: 'about' },
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")', section: 'series' },
{ key: 'seriesTitle', label: 'Series Title', section: 'series' },
{ key: 'seriesDescription', label: 'Series Description', multiline: true, section: 'series' },
{ key: 'seriesImageUrl', label: 'Series Cover Image URL', section: 'series' },
{ key: 'seriesListenUrl', label: 'Series Listen URL', section: 'series' },
{ key: 'studyGuideTitle', label: 'Study Guide Title', section: 'series' },
{ key: 'studyGuideDescription', label: 'Study Guide Description', multiline: true, section: 'series' },
{ key: 'studyGuideUrl', label: 'Study Guide URL (Amazon link)', section: 'series' },
{ key: 'shareHeading', label: 'Share Section — Heading', section: 'share' },
{ key: 'shareP', label: 'Share Section — Paragraph', multiline: true, section: 'share' },
] ]
export default function AdminPage({ content, onSave, onLogout }: Props) { export default function AdminPage({ content, onSave, onLogout }: Props) {
const [form, setForm] = useState<SiteContent>(content) const [form, setForm] = useState<SiteContent>(content)
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(content))
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
const [adminTab, setAdminTab] = useState<'content' | 'stats' | 'questions'>('content') const [adminTab, setAdminTab] = useState<'content' | 'stats' | 'questions' | 'brand'>('content')
const [contentTab, setContentTab] = useState<'main' | 'custom'>('main') const [contentTab, setContentTab] = useState<'main' | 'custom'>('main')
const [stats, setStats] = useState<AdminStats | null>(null) const [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
@@ -110,6 +188,25 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null) const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null)
const [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({}) const [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({})
const isDirty = JSON.stringify(form) !== lastSavedSnapshot
useEffect(() => {
const nextSnapshot = JSON.stringify(content)
setForm(content)
setLastSavedSnapshot(nextSnapshot)
}, [content])
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isDirty) return
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isDirty])
useEffect(() => { useEffect(() => {
fetch('/api/admin-stats') fetch('/api/admin-stats')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats')))) .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats'))))
@@ -300,6 +397,23 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setForm(f => ({ ...f, [key]: value })) setForm(f => ({ ...f, [key]: value }))
} }
function confirmLeaveUnsavedChanges() {
if (!isDirty) return true
return confirm('You have unsaved changes. Leave this section without saving?')
}
function handleAdminTabChange(nextTab: 'content' | 'stats' | 'questions' | 'brand') {
if (nextTab === adminTab) return
if (!confirmLeaveUnsavedChanges()) return
setAdminTab(nextTab)
}
function handleContentTabChange(nextTab: 'main' | 'custom') {
if (nextTab === contentTab) return
if (!confirmLeaveUnsavedChanges()) return
setContentTab(nextTab)
}
function addLink() { function addLink() {
setForm(f => ({ setForm(f => ({
...f, ...f,
@@ -581,6 +695,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
throw new Error((data as { message?: string }).message ?? 'Save failed') throw new Error((data as { message?: string }).message ?? 'Save failed')
} }
onSave(form) onSave(form)
setLastSavedSnapshot(JSON.stringify(form))
setStatus('saved') setStatus('saved')
setTimeout(() => setStatus('idle'), 3500) setTimeout(() => setStatus('idle'), 3500)
} catch (err) { } catch (err) {
@@ -656,8 +771,23 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<h1>Site Admin</h1> <h1>Site Admin</h1>
<p className="admin-sub">Verse by Verse with Nate</p> <p className="admin-sub">Verse by Verse with Nate</p>
<div className="admin-header-actions"> <div className="admin-header-actions">
<Link to="/" className="admin-back"> Back to site</Link> <Link
<button type="button" className="btn-admin-logout" onClick={() => void onLogout()}> to="/"
className="admin-back"
onClick={e => {
if (!confirmLeaveUnsavedChanges()) e.preventDefault()
}}
>
Back to site
</Link>
<button
type="button"
className="btn-admin-logout"
onClick={() => {
if (!confirmLeaveUnsavedChanges()) return
void onLogout()
}}
>
Log Out Log Out
</button> </button>
</div> </div>
@@ -670,7 +800,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
role="tab" role="tab"
aria-selected={adminTab === 'content'} aria-selected={adminTab === 'content'}
className={`admin-tab ${adminTab === 'content' ? 'admin-tab--active' : ''}`} className={`admin-tab ${adminTab === 'content' ? 'admin-tab--active' : ''}`}
onClick={() => setAdminTab('content')} onClick={() => handleAdminTabChange('content')}
> >
Content Content
</button> </button>
@@ -679,7 +809,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
role="tab" role="tab"
aria-selected={adminTab === 'stats'} aria-selected={adminTab === 'stats'}
className={`admin-tab ${adminTab === 'stats' ? 'admin-tab--active' : ''}`} className={`admin-tab ${adminTab === 'stats' ? 'admin-tab--active' : ''}`}
onClick={() => setAdminTab('stats')} onClick={() => handleAdminTabChange('stats')}
> >
Site Stats Site Stats
</button> </button>
@@ -688,12 +818,82 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
role="tab" role="tab"
aria-selected={adminTab === 'questions'} aria-selected={adminTab === 'questions'}
className={`admin-tab ${adminTab === 'questions' ? 'admin-tab--active' : ''}`} className={`admin-tab ${adminTab === 'questions' ? 'admin-tab--active' : ''}`}
onClick={() => setAdminTab('questions')} onClick={() => handleAdminTabChange('questions')}
> >
Questions ({questions.length}) Questions ({questions.length})
</button> </button>
<button
type="button"
role="tab"
aria-selected={adminTab === 'brand'}
className={`admin-tab ${adminTab === 'brand' ? 'admin-tab--active' : ''}`}
onClick={() => handleAdminTabChange('brand')}
>
Brand Kit
</button>
</div> </div>
{adminTab === 'brand' && (
<section className="admin-brand-kit" aria-label="Brand kit">
<div className="admin-stats-head">
<h2>Brand Kit</h2>
<p>Reference palette, typography, and implementation status for the live site.</p>
</div>
<div className="admin-brand-hero">
<img src="/images/podcast-art.jpeg" alt="Verse by Verse with Nate artwork" />
<div>
<p className="admin-brand-kicker">Verse by Verse with Nate</p>
<h3>A Journey Through Scripture</h3>
<p className="admin-brand-tagline">Verse by verse. Nugget by nugget.</p>
</div>
</div>
<div className="admin-brand-grid">
<article className="admin-brand-card">
<h3>Color Palette</h3>
<div className="admin-brand-swatches">
{BRAND_KIT_SWATCHES.map(swatch => (
<div key={swatch.hex} className="admin-brand-swatch">
<div className="admin-brand-swatch-block" style={{ background: swatch.hex }} />
<div className="admin-brand-swatch-copy">
<strong>{swatch.name}</strong>
<span>{swatch.hex}</span>
<p>{swatch.role}</p>
</div>
</div>
))}
</div>
</article>
<article className="admin-brand-card">
<h3>Typography</h3>
<div className="admin-brand-type-list">
{BRAND_KIT_TYPE.map(item => (
<div key={item.label} className="admin-brand-type-row">
<p className="admin-brand-type-label">{item.label}</p>
<p className="admin-brand-type-spec">{item.spec}</p>
<p className="admin-brand-type-sample">{item.sample}</p>
</div>
))}
</div>
</article>
</div>
<article className="admin-brand-card admin-brand-card--verify">
<h3>Verified On Site</h3>
<ul className="admin-brand-checklist">
{BRAND_KIT_VERIFICATION.map(item => (
<li key={item}>{item}</li>
))}
</ul>
<p className="admin-brand-note">
This admin panel reflects the live implementation now loaded from the shared font import and brand tokens.
</p>
</article>
</section>
)}
{adminTab === 'stats' && ( {adminTab === 'stats' && (
<section className="admin-stats" aria-label="Site hit statistics"> <section className="admin-stats" aria-label="Site hit statistics">
<div className="admin-stats-head"> <div className="admin-stats-head">
@@ -967,7 +1167,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
role="tab" role="tab"
aria-selected={contentTab === 'main'} aria-selected={contentTab === 'main'}
className={`admin-tab ${contentTab === 'main' ? 'admin-tab--active' : ''}`} className={`admin-tab ${contentTab === 'main' ? 'admin-tab--active' : ''}`}
onClick={() => setContentTab('main')} onClick={() => handleContentTabChange('main')}
> >
Main Content Main Content
</button> </button>
@@ -976,12 +1176,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
role="tab" role="tab"
aria-selected={contentTab === 'custom'} aria-selected={contentTab === 'custom'}
className={`admin-tab ${contentTab === 'custom' ? 'admin-tab--active' : ''}`} className={`admin-tab ${contentTab === 'custom' ? 'admin-tab--active' : ''}`}
onClick={() => setContentTab('custom')} onClick={() => handleContentTabChange('custom')}
> >
Custom Content Custom Content
</button> </button>
</div> </div>
{isDirty && <p className="admin-status admin-status--warn">Unsaved changes</p>}
{contentTab === 'main' && ( {contentTab === 'main' && (
<> <>
<div className="admin-archive-helper"> <div className="admin-archive-helper">
@@ -995,26 +1197,41 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</button> </button>
</div> </div>
{FIELDS.map(({ key, label, multiline }) => ( {MAIN_CONTENT_SECTIONS.map(section => {
<div className="admin-field" key={key}> const sectionFields = FIELDS.filter(field => field.section === section.id)
<label htmlFor={`field-${key}`}>{label}</label>
{multiline ? ( return (
<textarea <section className="admin-form-section" key={section.id}>
id={`field-${key}`} <div className="admin-section-header admin-section-header--compact">
value={form[key] as string} <h3>{section.title}</h3>
onChange={e => handleChange(key, e.target.value)} <p>{section.description}</p>
rows={4} </div>
/>
) : ( <div className="admin-form-section-fields">
<input {sectionFields.map(({ key, label, multiline }) => (
id={`field-${key}`} <div className="admin-field" key={key}>
type="text" <label htmlFor={`field-${key}`}>{label}</label>
value={form[key] as string} {multiline ? (
onChange={e => handleChange(key, e.target.value)} <textarea
/> id={`field-${key}`}
)} value={form[key] as string}
</div> onChange={e => handleChange(key, e.target.value)}
))} rows={4}
/>
) : (
<input
id={`field-${key}`}
type="text"
value={form[key] as string}
onChange={e => handleChange(key, e.target.value)}
/>
)}
</div>
))}
</div>
</section>
)
})}
</> </>
)} )}
+689 -389
View File
File diff suppressed because it is too large Load Diff
+162 -8
View File
@@ -65,6 +65,17 @@ export interface ArchivedSeries {
export interface SiteContent { export interface SiteContent {
eyebrow: string eyebrow: string
heroTagline: 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 aboutShowHeading: string
aboutShowP1: string aboutShowP1: string
aboutShowP2: string aboutShowP2: string
@@ -87,6 +98,21 @@ export interface SiteContent {
export const DEFAULTS: SiteContent = { export const DEFAULTS: SiteContent = {
eyebrow: 'A Journey Through Scripture', eyebrow: 'A Journey Through Scripture',
heroTagline: "Exploring God's Word one verse at a time", 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.', aboutShowHeading: 'Depth. Clarity. Application.',
aboutShowP1: 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.', '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.',
@@ -193,6 +219,33 @@ function QASection() {
setPage(0) setPage(0)
} }
const tokenizeForRelated = (text: string) =>
text
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/)
.filter(token => token.length > 3)
const getRelatedQuestions = (current: PublicQuestion) => {
const currentTokens = new Set(tokenizeForRelated(`${current.question} ${current.answer}`))
const related = 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 related
}
return ( return (
<section id="qa" className="section-qa" aria-label="Questions and answers"> <section id="qa" className="section-qa" aria-label="Questions and answers">
<div className="section-inner"> <div className="section-inner">
@@ -265,6 +318,28 @@ function QASection() {
<div className="qa-card-face qa-card-back"> <div className="qa-card-face qa-card-back">
<span className="qa-face-label">A</span> <span className="qa-face-label">A</span>
<p className="qa-answer-text">{question.answer}</p> <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> <p style={{ margin: '0.75rem 0 0', fontSize: '0.8rem', color: '#a89060', fontStyle: 'italic' }}> Answered by Nate</p>
</div> </div>
</div> </div>
@@ -480,6 +555,7 @@ function ShareShowButton() {
function LandingPage({ content }: { content: SiteContent }) { function LandingPage({ content }: { content: SiteContent }) {
const archivedSeries = content.archivedSeries ?? [] const archivedSeries = content.archivedSeries ?? []
const [seriesView, setSeriesView] = useState<'current' | 'archive'>('current')
const [activeArchivedIndex, setActiveArchivedIndex] = useState(0) const [activeArchivedIndex, setActiveArchivedIndex] = useState(0)
useEffect(() => { useEffect(() => {
@@ -496,6 +572,16 @@ function LandingPage({ content }: { content: SiteContent }) {
return () => window.clearInterval(intervalId) return () => window.clearInterval(intervalId)
}, [archivedSeries.length]) }, [archivedSeries.length])
useEffect(() => {
if (window.location.hash === '#archives' && archivedSeries.length > 0) {
setSeriesView('archive')
return
}
if (window.location.hash === '#series') {
setSeriesView('current')
}
}, [archivedSeries.length])
return ( return (
<div className="site"> <div className="site">
{/* ── HEADER ── */} {/* ── HEADER ── */}
@@ -503,10 +589,14 @@ function LandingPage({ content }: { content: SiteContent }) {
<div className="header-inner"> <div className="header-inner">
<span className="header-ornament"> &nbsp; &nbsp; </span> <span className="header-ornament"> &nbsp; &nbsp; </span>
<nav className="header-nav"> <nav className="header-nav">
<Link to="/start-here">New Here</Link>
<a href="#listen">Listen</a> <a href="#listen">Listen</a>
<a href="#about">About</a> <a href="#about">About</a>
<a href="#qa">Q&amp;A</a> <a href="#qa">Q&amp;A</a>
<a href="#series">Series</a> <a href="#series">Series</a>
{archivedSeries.length > 0 && (
<a href="#archives" onClick={() => setSeriesView('archive')}>Archived</a>
)}
<a href="#contact">Contact</a> <a href="#contact">Contact</a>
<a <a
href={SPOTIFY_SHOW_URL} href={SPOTIFY_SHOW_URL}
@@ -514,7 +604,7 @@ function LandingPage({ content }: { content: SiteContent }) {
rel="noreferrer" rel="noreferrer"
className="header-cta" className="header-cta"
> >
Subscribe Free Follow on Spotify
</a> </a>
</nav> </nav>
</div> </div>
@@ -532,11 +622,7 @@ function LandingPage({ content }: { content: SiteContent }) {
</div> </div>
<div className="hero-text"> <div className="hero-text">
<p className="eyebrow">{content.eyebrow}</p> <p className="eyebrow">{content.eyebrow}</p>
<h1 className="hero-title"> <h1 className="hero-title">Verse by Verse</h1>
<span className="verse-word">VERSE</span>
<span className="by-label">· by ·</span>
<span className="verse-word">VERSE</span>
</h1>
<p className="with-nate"> <p className="with-nate">
<span className="word-with">with </span>Nate <span className="word-with">with </span>Nate
</p> </p>
@@ -627,6 +713,36 @@ function LandingPage({ content }: { content: SiteContent }) {
</section> </section>
{/* ── SERIES ── */} {/* ── SERIES ── */}
{archivedSeries.length > 0 && (
<section className="section-series-toggle" aria-label="Series view toggle">
<div className="section-inner">
<div className="series-toggle-wrap">
<button
type="button"
className={`series-toggle-btn ${seriesView === 'current' ? 'series-toggle-btn--active' : ''}`}
onClick={() => {
setSeriesView('current')
window.location.hash = 'series'
}}
>
Current Study
</button>
<button
type="button"
className={`series-toggle-btn ${seriesView === 'archive' ? 'series-toggle-btn--active' : ''}`}
onClick={() => {
setSeriesView('archive')
window.location.hash = 'archives'
}}
>
Archived Studies
</button>
</div>
</div>
</section>
)}
{seriesView === 'current' && (
<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">
@@ -658,9 +774,10 @@ function LandingPage({ content }: { content: SiteContent }) {
</div> </div>
</div> </div>
</section> </section>
)}
{archivedSeries.length > 0 && ( {archivedSeries.length > 0 && seriesView === 'archive' && (
<section className="section-archive-series" aria-label="Archived studies"> <section className="section-archive-series" id="archives" aria-label="Archived studies">
<div className="section-inner"> <div className="section-inner">
<h2 className="section-heading"> <h2 className="section-heading">
<span className="ornament"></span> Archived Studies <span className="ornament"></span> <span className="ornament"></span> Archived Studies <span className="ornament"></span>
@@ -920,6 +1037,42 @@ function LandingPage({ content }: { content: SiteContent }) {
) )
} }
function StartHerePage({ content }: { content: SiteContent }) {
return (
<main className="thanks-page" aria-label="Start Here">
<div className="thanks-card" style={{ maxWidth: '980px', width: '100%' }}>
<p className="eyebrow">Verse by Verse with Nate</p>
<h1>{content.startHereHeading}</h1>
<p>
{content.startHereIntro}
</p>
<div className="start-grid" style={{ marginTop: '1.25rem' }}>
<article className="start-card">
<h3>{content.startHereStep1Title}</h3>
<p>{content.startHereStep1Body}</p>
<a href="/#listen" className="btn-secondary">{content.startHereStep1Cta}</a>
</article>
<article className="start-card">
<h3>{content.startHereStep2Title}</h3>
<p>{content.startHereStep2Body}</p>
<a href="/#qa" className="btn-secondary">{content.startHereStep2Cta}</a>
</article>
<article className="start-card">
<h3>{content.startHereStep3Title}</h3>
<p>{content.startHereStep3Body}</p>
<a href="/#contact" className="btn-primary">{content.startHereStep3Cta}</a>
</article>
</div>
<div style={{ marginTop: '1.4rem' }}>
<Link to="/" className="btn-secondary">Back to Site</Link>
</div>
</div>
</main>
)
}
function ThankYouPage() { function ThankYouPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [secondsLeft, setSecondsLeft] = useState(15) const [secondsLeft, setSecondsLeft] = useState(15)
@@ -1203,6 +1356,7 @@ export default function App() {
return ( return (
<Routes> <Routes>
<Route path="/" element={<LandingPage content={content} />} /> <Route path="/" element={<LandingPage content={content} />} />
<Route path="/start-here" element={<StartHerePage content={content} />} />
<Route path="/questions" element={<QuestionsPage />} /> <Route path="/questions" element={<QuestionsPage />} />
<Route path="/thanks" element={<ThankYouPage />} /> <Route path="/thanks" element={<ThankYouPage />} />
<Route path="/subscribe" element={<SubscribePage />} /> <Route path="/subscribe" element={<SubscribePage />} />
+14 -3
View File
@@ -3,10 +3,21 @@
} }
:root { :root {
font-family: 'Barlow Condensed', 'Trebuchet MS', sans-serif; --brand-black: #0a0a08;
--brand-black-2: #0f0f0c;
--brand-black-3: #1a1a15;
--brand-border: #2a2518;
--brand-gold: #c9a84c;
--brand-gold-light: #e0c070;
--brand-gold-dark: #8a6e28;
--brand-warm-white: #f0ead8;
--brand-muted: #7a7060;
--brand-font-body: 'Cormorant Garamond', Georgia, serif;
--brand-font-heading: 'Cormorant Garamond', Georgia, serif;
font-family: var(--brand-font-body);
line-height: 1.5; line-height: 1.5;
color: #f0e6d0; color: var(--brand-warm-white);
background: #0a0a0a; background: var(--brand-black);
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;