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:
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user