Add testimonials section and Q&A submit CTA; v1.0.8

- Testimonials: new Testimonial type, CMS field, admin UI under About,
  and TestimonialsSection rendered on homepage and About page (hidden when empty)
- Q&A: "Submit a Question →" CTA at bottom of Q&A page linking to /contact

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-22 12:37:46 -04:00
parent a7c5906c7b
commit b60604aa80
6 changed files with 158 additions and 3 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "1.0.7", "version": "1.0.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+37 -1
View File
@@ -702,7 +702,7 @@ interface PodcastChecklistData {
episodes: PodcastChecklistEpisode[] episodes: PodcastChecklistEpisode[]
} }
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'redirects' | 'podcastFeaturedLinks' | 'finishedBooks' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'> type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'redirects' | 'podcastFeaturedLinks' | 'finishedBooks' | 'testimonials' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
type AdminView = type AdminView =
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact' | 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
@@ -3688,6 +3688,42 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</div> </div>
))} ))}
{renderSaveStatus()} {renderSaveStatus()}
<div className="admin-panel-subhead" id="about-testimonials">
<h3>Listener Testimonials</h3>
<p>Quotes shown on the About page and homepage. Add Apple Podcast reviews, listener emails, or any kind words.</p>
</div>
{(form.testimonials ?? []).length === 0 && (
<p className="admin-stats-note">No testimonials yet. Add one below.</p>
)}
{(form.testimonials ?? []).map(item => (
<AdminCollapsibleCard
key={item.id}
title={item.name || 'Unnamed listener'}
subtitle={item.source || 'No source'}
>
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`testimonial-quote-${item.id}`}>Quote</label>
<textarea id={`testimonial-quote-${item.id}`} rows={3} placeholder="What they said…" value={item.quote} onChange={e => setForm(f => ({ ...f, testimonials: (f.testimonials ?? []).map(t => t.id === item.id ? { ...t, quote: e.target.value } : t) }))} />
</div>
<div className="admin-field">
<label htmlFor={`testimonial-name-${item.id}`}>Name</label>
<input id={`testimonial-name-${item.id}`} type="text" placeholder="e.g. Sarah M." value={item.name} onChange={e => setForm(f => ({ ...f, testimonials: (f.testimonials ?? []).map(t => t.id === item.id ? { ...t, name: e.target.value } : t) }))} />
</div>
<div className="admin-field">
<label htmlFor={`testimonial-source-${item.id}`}>Source (optional)</label>
<input id={`testimonial-source-${item.id}`} type="text" placeholder="e.g. Apple Podcasts" value={item.source ?? ''} onChange={e => setForm(f => ({ ...f, testimonials: (f.testimonials ?? []).map(t => t.id === item.id ? { ...t, source: e.target.value } : t) }))} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => setForm(f => ({ ...f, testimonials: (f.testimonials ?? []).filter(t => t.id !== item.id) }))}>Remove</button>
</div>
</AdminCollapsibleCard>
))}
<button type="button" className="btn-admin-add" onClick={() => setForm(f => ({ ...f, testimonials: [...(f.testimonials ?? []), { id: Date.now().toString(36), quote: '', name: '', source: '' }] }))}>
+ Add Testimonial
</button>
</section> </section>
)} )}
+77
View File
@@ -1370,6 +1370,83 @@
box-sizing: border-box; box-sizing: border-box;
} }
/* ── Testimonials ── */
.section-testimonials {
padding: 4rem 0;
background: #0a0a0a;
border-top: 1px solid rgba(201, 168, 76, 0.12);
border-bottom: 1px solid rgba(201, 168, 76, 0.12);
}
.testimonials-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
}
.testimonial-card {
background: rgba(201, 168, 76, 0.05);
border: 1px solid rgba(201, 168, 76, 0.18);
border-radius: 12px;
padding: 1.5rem;
margin: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.testimonial-quote {
font-family: var(--brand-font-body);
font-size: 0.97rem;
line-height: 1.7;
color: var(--brand-warm-white);
font-style: italic;
margin: 0;
}
.testimonial-footer {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-top: auto;
}
.testimonial-name {
font-family: var(--brand-font-body);
font-size: 0.85rem;
color: #d8c395;
font-weight: 600;
}
.testimonial-source {
font-size: 0.78rem;
color: rgba(201, 168, 76, 0.55);
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* ── Q&A submit CTA ── */
.qa-submit-cta {
margin-top: 3rem;
padding: 2rem;
border: 1px solid rgba(201, 168, 76, 0.2);
border-radius: 12px;
background: rgba(201, 168, 76, 0.04);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.qa-submit-cta-text {
font-family: var(--brand-font-body);
font-size: 1rem;
color: var(--brand-warm-white);
margin: 0;
}
.section-share { .section-share {
background: #080808; background: #080808;
border-top: 1px solid rgba(201, 168, 76, 0.18); border-top: 1px solid rgba(201, 168, 76, 0.18);
+28 -1
View File
@@ -6,7 +6,7 @@ import QASection from './components/QASection'
import ContactForm from './components/ContactForm' import ContactForm from './components/ContactForm'
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy' import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy'
import { SpotifyIcon } from './icons' import { SpotifyIcon } from './icons'
import type { SiteContent, StudyProgram } from './content' import type { SiteContent, StudyProgram, Testimonial } from './content'
import { DEFAULTS } from './content' import { DEFAULTS } from './content'
import { usePageMeta } from './hooks/usePageMeta' import { usePageMeta } from './hooks/usePageMeta'
import { useGlobalSearch } from './hooks/useGlobalSearch' import { useGlobalSearch } from './hooks/useGlobalSearch'
@@ -1078,6 +1078,30 @@ function HomepageQACallout() {
) )
} }
function TestimonialsSection({ testimonials }: { testimonials: Testimonial[] }) {
if (!testimonials || testimonials.length === 0) return null
return (
<section className="section-testimonials" aria-label="Listener testimonials">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament">✦</span> What Listeners Are Saying <span className="ornament">✦</span>
</h2>
<div className="testimonials-grid">
{testimonials.map(t => (
<blockquote key={t.id} className="testimonial-card">
<p className="testimonial-quote">"{t.quote}"</p>
<footer className="testimonial-footer">
<span className="testimonial-name">— {t.name}</span>
{t.source && <span className="testimonial-source">{t.source}</span>}
</footer>
</blockquote>
))}
</div>
</div>
</section>
)
}
function LandingPage({ content }: { content: SiteContent }) { function LandingPage({ content }: { content: SiteContent }) {
const featuredStudy = getHomepageFeaturedStudy(content) const featuredStudy = getHomepageFeaturedStudy(content)
@@ -1232,6 +1256,8 @@ function LandingPage({ content }: { content: SiteContent }) {
<HomepageQACallout /> <HomepageQACallout />
<TestimonialsSection testimonials={content.testimonials ?? []} />
<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">
@@ -1622,6 +1648,7 @@ function AboutPage({ content }: { content: SiteContent }) {
<SiteHeader content={content} /> <SiteHeader content={content} />
<SiteSearchBar content={content} /> <SiteSearchBar content={content} />
<AboutSection content={content} /> <AboutSection content={content} />
<TestimonialsSection testimonials={content.testimonials ?? []} />
<CustomBlocksSection content={content} page="about" /> <CustomBlocksSection content={content} page="about" />
<SiteFooter content={content} /> <SiteFooter content={content} />
<AnalyticsConsentBanner content={content} /> <AnalyticsConsentBanner content={content} />
+5
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
interface PublicQuestion { interface PublicQuestion {
id: string id: string
@@ -1009,6 +1010,10 @@ export default function QASection() {
)} )}
</> </>
)} )}
<div className="qa-submit-cta">
<p className="qa-submit-cta-text">Have a question about Scripture or the podcast?</p>
<Link to="/contact" className="btn-primary">Submit a Question </Link>
</div>
</div> </div>
</section> </section>
) )
+10
View File
@@ -66,6 +66,13 @@ export interface PodcastFeaturedLink {
discussionQuestions?: string[] discussionQuestions?: string[]
} }
export interface Testimonial {
id: string
quote: string
name: string
source?: string
}
export interface FinishedBook { export interface FinishedBook {
id: string id: string
title: string title: string
@@ -247,6 +254,8 @@ export interface SiteContent {
episodesSeoIntro: string episodesSeoIntro: string
// ── Finished Books ── // ── Finished Books ──
finishedBooks: FinishedBook[] finishedBooks: FinishedBook[]
// ── Testimonials ──
testimonials: Testimonial[]
} }
export const DEFAULTS: SiteContent = { export const DEFAULTS: SiteContent = {
@@ -447,6 +456,7 @@ export const DEFAULTS: SiteContent = {
], ],
podcastFeaturedLinks: [], podcastFeaturedLinks: [],
finishedBooks: [], finishedBooks: [],
testimonials: [],
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.', 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',