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:
+37
-1
@@ -702,7 +702,7 @@ interface PodcastChecklistData {
|
||||
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 =
|
||||
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
|
||||
@@ -3688,6 +3688,42 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</div>
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
|
||||
|
||||
+77
@@ -1370,6 +1370,83 @@
|
||||
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 {
|
||||
background: #080808;
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.18);
|
||||
|
||||
+28
-1
@@ -6,7 +6,7 @@ import QASection from './components/QASection'
|
||||
import ContactForm from './components/ContactForm'
|
||||
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy'
|
||||
import { SpotifyIcon } from './icons'
|
||||
import type { SiteContent, StudyProgram } from './content'
|
||||
import type { SiteContent, StudyProgram, Testimonial } from './content'
|
||||
import { DEFAULTS } from './content'
|
||||
import { usePageMeta } from './hooks/usePageMeta'
|
||||
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 }) {
|
||||
const featuredStudy = getHomepageFeaturedStudy(content)
|
||||
|
||||
@@ -1232,6 +1256,8 @@ function LandingPage({ content }: { content: SiteContent }) {
|
||||
|
||||
<HomepageQACallout />
|
||||
|
||||
<TestimonialsSection testimonials={content.testimonials ?? []} />
|
||||
|
||||
<section className="section-share" aria-label="Share the show">
|
||||
<div className="section-inner section-share-inner">
|
||||
<div className="share-text">
|
||||
@@ -1622,6 +1648,7 @@ function AboutPage({ content }: { content: SiteContent }) {
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<AboutSection content={content} />
|
||||
<TestimonialsSection testimonials={content.testimonials ?? []} />
|
||||
<CustomBlocksSection content={content} page="about" />
|
||||
<SiteFooter content={content} />
|
||||
<AnalyticsConsentBanner content={content} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
interface PublicQuestion {
|
||||
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>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -66,6 +66,13 @@ export interface PodcastFeaturedLink {
|
||||
discussionQuestions?: string[]
|
||||
}
|
||||
|
||||
export interface Testimonial {
|
||||
id: string
|
||||
quote: string
|
||||
name: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface FinishedBook {
|
||||
id: string
|
||||
title: string
|
||||
@@ -247,6 +254,8 @@ export interface SiteContent {
|
||||
episodesSeoIntro: string
|
||||
// ── Finished Books ──
|
||||
finishedBooks: FinishedBook[]
|
||||
// ── Testimonials ──
|
||||
testimonials: Testimonial[]
|
||||
}
|
||||
|
||||
export const DEFAULTS: SiteContent = {
|
||||
@@ -447,6 +456,7 @@ export const DEFAULTS: SiteContent = {
|
||||
],
|
||||
podcastFeaturedLinks: [],
|
||||
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.',
|
||||
seo: {
|
||||
title: 'Verse by Verse with Nate',
|
||||
|
||||
Reference in New Issue
Block a user