Polish hero UX and add admin brand kit panel
This commit is contained in:
+1
-1
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+162
-8
@@ -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">✦ ✦ ✦</span>
|
<span className="header-ornament">✦ ✦ ✦</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&A</a>
|
<a href="#qa">Q&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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user