Fix Truth For Life devotional widget not rendering
Replaced broken srcdoc iframe approach with direct script injection so the TFL syndicate.js runs in page context with access to the DOM it needs. Also added truthforlife.org and ajax.googleapis.com to CSP script-src, which was silently blocking the widget and its jQuery dependency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -68,7 +68,7 @@ app.use((_req, res, next) => {
|
|||||||
'Content-Security-Policy',
|
'Content-Security-Policy',
|
||||||
[
|
[
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
"script-src 'self' 'unsafe-inline'",
|
"script-src 'self' 'unsafe-inline' https://www.truthforlife.org https://ajax.googleapis.com",
|
||||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
"font-src 'self' https://fonts.gstatic.com",
|
"font-src 'self' https://fonts.gstatic.com",
|
||||||
"img-src 'self' data: https:",
|
"img-src 'self' data: https:",
|
||||||
|
|||||||
+28
-210
@@ -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, ArchivedSeries, StudyProgram } from './content'
|
import type { SiteContent, StudyProgram } 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'
|
||||||
@@ -253,18 +253,10 @@ function buildCustomResourceDownloadId(id: string) {
|
|||||||
return `custom:${id}`
|
return `custom:${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildArchivedResourceDownloadId(seriesId: string, linkId: string) {
|
|
||||||
return `archived:${seriesId}:${linkId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCustomDownloadPageId(id: string) {
|
function buildCustomDownloadPageId(id: string) {
|
||||||
return `custom--${id}`
|
return `custom--${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildArchivedDownloadPageId(seriesId: string, linkId: string) {
|
|
||||||
return `archived--${seriesId}--${linkId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DownloadPageResource {
|
interface DownloadPageResource {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
@@ -298,25 +290,6 @@ function resolveDownloadPageResource(content: SiteContent, pageId: string | unde
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageId.startsWith('archived--')) {
|
|
||||||
const [, seriesId, linkId] = pageId.split('--')
|
|
||||||
const series = (content.archivedSeries ?? []).find(item => item.id === seriesId)
|
|
||||||
const link = (series?.resourceLinks ?? []).find(item => item.id === linkId)
|
|
||||||
if (!series || !link) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: pageId,
|
|
||||||
label: link.label || series.title || 'Download Resource',
|
|
||||||
imageUrl: series.imageUrl,
|
|
||||||
summary: link.description || series.description || 'Fill out the form below to access this download from a previous study.',
|
|
||||||
tags: [],
|
|
||||||
buttonText: `Download ${link.label || series.title || 'Resource'}`,
|
|
||||||
resourceId: buildArchivedResourceDownloadId(series.id, link.id),
|
|
||||||
amazonUrl: link.amazonUrl,
|
|
||||||
amazonLabel: link.amazonLabel,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,23 +341,6 @@ function AnalyticsConsentBanner({ content }: { content: SiteContent }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Episode {
|
|
||||||
title: string
|
|
||||||
pubDate: string
|
|
||||||
link: string
|
|
||||||
description: string
|
|
||||||
duration: string
|
|
||||||
episode: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPubDate(raw: string): string {
|
|
||||||
try {
|
|
||||||
return new Date(raw).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
|
||||||
} catch {
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function SiteSearchBar({ content }: { content: SiteContent }) {
|
function SiteSearchBar({ content }: { content: SiteContent }) {
|
||||||
const { query, setQuery, results } = useGlobalSearch(content)
|
const { query, setQuery, results } = useGlobalSearch(content)
|
||||||
return (
|
return (
|
||||||
@@ -730,53 +686,26 @@ function PodcastHighlightsSection({ content }: { content: SiteContent }) {
|
|||||||
|
|
||||||
function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
||||||
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
|
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
|
||||||
const archivedWithResources = (content.archivedSeries ?? []).filter(series => (series.resourceLinks ?? []).length > 0)
|
|
||||||
const [activeTag, setActiveTag] = useState<string | null>(null)
|
const [activeTag, setActiveTag] = useState<string | null>(null)
|
||||||
const [activeType, setActiveType] = useState<'all' | 'resource' | 'series'>('all')
|
|
||||||
|
|
||||||
if (resources.length === 0 && archivedWithResources.length === 0) return null
|
if (resources.length === 0) return null
|
||||||
|
|
||||||
const allTags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
|
const allTags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
|
||||||
|
|
||||||
// Filter resources
|
|
||||||
const filteredResources = resources.filter(r => {
|
const filteredResources = resources.filter(r => {
|
||||||
const tagOk = !activeTag || (r.tags ?? []).includes(activeTag)
|
return !activeTag || (r.tags ?? []).includes(activeTag)
|
||||||
const typeOk = activeType === 'all' || activeType === 'resource'
|
|
||||||
return tagOk && typeOk
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Filter archived series
|
|
||||||
const filteredArchived = archivedWithResources.filter(() => {
|
|
||||||
return activeType === 'all' || activeType === 'series'
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasTypeFilter = resources.length > 0 && archivedWithResources.length > 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="section-resources section-download-library" aria-label="Download library">
|
<section className="section-resources section-download-library" aria-label="Download library">
|
||||||
<div className="section-inner">
|
<div className="section-inner">
|
||||||
<div className="download-library-head">
|
<div className="download-library-head">
|
||||||
<p className="eyebrow">Download Library</p>
|
<p className="eyebrow">Download Library</p>
|
||||||
<h2 className="section-heading">Guides, worksheets, and past study downloads.</h2>
|
<h2 className="section-heading">Guides, worksheets, and study downloads.</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Type + tag filter bar */}
|
{allTags.length > 0 && (
|
||||||
<div className="download-filter-bar">
|
<div className="download-filter-bar">
|
||||||
{hasTypeFilter && (
|
|
||||||
<div className="download-tag-filter" style={{ marginBottom: allTags.length > 0 ? '0.5rem' : 0 }}>
|
|
||||||
{(['all', 'resource', 'series'] as const).map(t => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
type="button"
|
|
||||||
className={`download-tag-chip${activeType === t ? ' download-tag-chip--active' : ''}`}
|
|
||||||
onClick={() => setActiveType(t)}
|
|
||||||
>
|
|
||||||
{t === 'all' ? 'All types' : t === 'resource' ? 'Study guides' : 'Past series'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{allTags.length > 0 && (activeType === 'all' || activeType === 'resource') && (
|
|
||||||
<div className="download-tag-filter">
|
<div className="download-tag-filter">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -799,16 +728,16 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="download-clear-link"
|
className="download-clear-link"
|
||||||
onClick={() => { setActiveTag(null); setActiveType('all') }}
|
onClick={() => setActiveTag(null)}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{filteredResources.length > 0 && (
|
{filteredResources.length > 0 ? (
|
||||||
<div className="download-library-group">
|
<div className="download-library-group">
|
||||||
<div className="resources-list">
|
<div className="resources-list">
|
||||||
{filteredResources.map(resource => (
|
{filteredResources.map(resource => (
|
||||||
@@ -830,86 +759,18 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
|
||||||
{activeTag !== null && filteredResources.length === 0 && activeType !== 'series' && (
|
|
||||||
<p className="download-empty-state">No downloads tagged "{activeTag}".</p>
|
<p className="download-empty-state">No downloads tagged "{activeTag}".</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredArchived.length > 0 && (
|
|
||||||
<div className="download-library-group">
|
|
||||||
<div className="download-library-group-head">
|
|
||||||
<h3>Previous Studies</h3>
|
|
||||||
<p>Downloads from earlier series.</p>
|
|
||||||
</div>
|
|
||||||
{filteredArchived.map(series => (
|
|
||||||
<div key={series.id} className="archive-series-resources">
|
|
||||||
<div className="download-library-series-head">
|
|
||||||
<h4>{series.title || 'Archived Study'}</h4>
|
|
||||||
{series.description && <p>{series.description}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="resources-list">
|
|
||||||
{(series.resourceLinks ?? []).map(link => (
|
|
||||||
<Link key={link.id} to={`/downloads/${buildArchivedDownloadPageId(series.id, link.id)}`} className="resource-download-card resource-download-card--link">
|
|
||||||
<div className="resource-download-header">
|
|
||||||
{series.imageUrl && (
|
|
||||||
<img src={series.imageUrl} alt={series.title || 'Archived study'} className="resource-link-image" loading="lazy" />
|
|
||||||
)}
|
|
||||||
<div className="resource-download-meta">
|
|
||||||
<span className="resource-link-label">{link.label || series.title || 'Download Resource'}</span>
|
|
||||||
{link.description && <p className="resource-link-description">{link.description}</p>}
|
|
||||||
<span className="resource-link-action">Open download page →</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function useEpisodesForSeries(seriesId: string | null, archivedSeries: ArchivedSeries[]) {
|
|
||||||
const [episodes, setEpisodes] = useState<Episode[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!seriesId) return
|
|
||||||
const series = archivedSeries.find(s => s.id === seriesId)
|
|
||||||
if (!series?.episodeRange) return
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
fetch('/api/episodes/all')
|
|
||||||
.then(r => r.ok ? r.json() : Promise.reject())
|
|
||||||
.then((data: { episodes?: Episode[] }) => {
|
|
||||||
const { from, to } = series.episodeRange!
|
|
||||||
const filtered = (data.episodes ?? []).filter(ep => {
|
|
||||||
const num = parseInt(ep.episode, 10)
|
|
||||||
return !isNaN(num) && num >= from && num <= to
|
|
||||||
})
|
|
||||||
setEpisodes(filtered)
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false))
|
|
||||||
}, [seriesId])
|
|
||||||
|
|
||||||
return { episodes, loading }
|
|
||||||
}
|
|
||||||
|
|
||||||
function DownloadDetailPage({ content }: { content: SiteContent }) {
|
function DownloadDetailPage({ content }: { content: SiteContent }) {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const resource = resolveDownloadPageResource(content, id)
|
const resource = resolveDownloadPageResource(content, id)
|
||||||
|
|
||||||
// Resolve related series for archived resources
|
|
||||||
const archivedSeriesId = id?.startsWith('archived--') ? id.split('--')[1] : null
|
|
||||||
const relatedSeries = archivedSeriesId ? (content.archivedSeries ?? []).find(s => s.id === archivedSeriesId) : null
|
|
||||||
const { episodes: relatedEpisodes, loading: epsLoading } = useEpisodesForSeries(archivedSeriesId, content.archivedSeries ?? [])
|
|
||||||
const spotifyUrl = content.platformSpotifyUrl || '/episodes'
|
|
||||||
|
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Download not found">
|
<main className="thanks-page" aria-label="Download not found">
|
||||||
@@ -953,39 +814,6 @@ function DownloadDetailPage({ content }: { content: SiteContent }) {
|
|||||||
<Link to="/resources" className="btn-secondary">Back to Downloads</Link>
|
<Link to="/resources" className="btn-secondary">Back to Downloads</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{relatedSeries && (relatedEpisodes.length > 0 || epsLoading) && (
|
|
||||||
<div className="download-related-episodes">
|
|
||||||
<h2 className="download-related-heading">
|
|
||||||
Episodes from {relatedSeries.title}
|
|
||||||
</h2>
|
|
||||||
{epsLoading ? (
|
|
||||||
<p style={{ color: '#7a7060', fontSize: '0.9rem' }}>Loading episodes…</p>
|
|
||||||
) : (
|
|
||||||
<div className="download-related-list">
|
|
||||||
{relatedEpisodes.slice(0, 10).map((ep, idx) => (
|
|
||||||
<a
|
|
||||||
key={idx}
|
|
||||||
href={ep.link || spotifyUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="download-related-ep"
|
|
||||||
>
|
|
||||||
<div className="download-related-ep-meta">
|
|
||||||
{ep.episode && <span className="episode-number">Ep. {ep.episode}</span>}
|
|
||||||
{ep.pubDate && <span className="episode-date">{formatPubDate(ep.pubDate)}</span>}
|
|
||||||
</div>
|
|
||||||
<p className="download-related-ep-title">{ep.title}</p>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
{relatedEpisodes.length > 10 && (
|
|
||||||
<Link to="/episodes" className="btn-secondary" style={{ marginTop: '0.5rem', display: 'inline-block' }}>
|
|
||||||
View all {relatedEpisodes.length} episodes →
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -1140,28 +968,23 @@ function HomepageNewsletterSection() {
|
|||||||
type TflWidgetType = 'audiodevo' | 'abdevotional' | 'resourcead'
|
type TflWidgetType = 'audiodevo' | 'abdevotional' | 'resourcead'
|
||||||
|
|
||||||
function TruthForLifeWidget({ type = 'audiodevo' }: { type?: TflWidgetType }) {
|
function TruthForLifeWidget({ type = 'audiodevo' }: { type?: TflWidgetType }) {
|
||||||
const iframeHeight = type === 'audiodevo' ? '420' : type === 'resourcead' ? '270' : '800'
|
|
||||||
const heading = type === 'resourcead' ? 'Recommended Resources' : 'Daily Devotional'
|
const heading = type === 'resourcead' ? 'Recommended Resources' : 'Daily Devotional'
|
||||||
|
const containerId = 'tfl-syndicate-container'
|
||||||
|
|
||||||
const srcdoc = `<!DOCTYPE html>
|
useEffect(() => {
|
||||||
<html>
|
const container = document.getElementById(containerId)
|
||||||
<head>
|
if (!container) return
|
||||||
<meta charset="utf-8" />
|
container.innerHTML = ''
|
||||||
<style>
|
|
||||||
html, body { margin: 0; padding: 0; background: #ffffff; overflow: hidden; }
|
const existing = document.getElementById('syn_script')
|
||||||
#tfl-syndicate-container { font-family: Georgia, serif; }
|
if (existing) existing.remove()
|
||||||
/* Hide the widget's own heading since our section already has one */
|
|
||||||
#tfl-syndicate-container h2,
|
const script = document.createElement('script')
|
||||||
#tfl-syndicate-container h3,
|
script.id = 'syn_script'
|
||||||
#tfl-syndicate-container > *:first-child > h2,
|
script.src = `https://www.truthforlife.org/static/js/responsive/lib/syndicate.js?type=${type}&id=458890`
|
||||||
#tfl-syndicate-container > *:first-child > h3 { display: none; }
|
script.type = 'text/javascript'
|
||||||
</style>
|
container.parentElement?.insertBefore(script, container)
|
||||||
</head>
|
}, [type, containerId])
|
||||||
<body>
|
|
||||||
<script id="syn_script" src="https://www.truthforlife.org/static/js/responsive/lib/syndicate.js?type=${type}&id=458890" type="text/javascript"></script>
|
|
||||||
<div id="tfl-syndicate-container"></div>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="section-tfl" aria-label="Truth For Life devotional" style={type === 'resourcead' ? { padding: '2.5rem 0' } : undefined}>
|
<section className="section-tfl" aria-label="Truth For Life devotional" style={type === 'resourcead' ? { padding: '2.5rem 0' } : undefined}>
|
||||||
@@ -1176,13 +999,9 @@ function TruthForLifeWidget({ type = 'audiodevo' }: { type?: TflWidgetType }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="tfl-widget-wrap">
|
<div className="tfl-widget-wrap">
|
||||||
<iframe
|
<div
|
||||||
srcDoc={srcdoc}
|
id="tfl-syndicate-container"
|
||||||
title="Truth For Life daily devotional"
|
style={{ maxWidth: type === 'resourcead' ? '500px' : '760px', margin: '0 auto' }}
|
||||||
width="100%"
|
|
||||||
height={iframeHeight}
|
|
||||||
style={{ border: 'none', display: 'block', borderRadius: '10px', maxWidth: type === 'resourcead' ? '500px' : '760px', margin: '0 auto' }}
|
|
||||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2233,7 +2052,7 @@ function PreviewPage() {
|
|||||||
if (previewView === 'start-here') return <StartHerePage content={content} />
|
if (previewView === 'start-here') return <StartHerePage content={content} />
|
||||||
if (previewView === 'about') return <AboutPage content={content} />
|
if (previewView === 'about') return <AboutPage content={content} />
|
||||||
if (previewView === 'contact') return <ContactPage content={content} />
|
if (previewView === 'contact') return <ContactPage content={content} />
|
||||||
if (previewView === 'current-series' || previewView === 'archived-series' || previewView === 'episode-highlights') {
|
if (previewView === 'current-series' || previewView === 'episode-highlights') {
|
||||||
return <EpisodesPage content={content} />
|
return <EpisodesPage content={content} />
|
||||||
}
|
}
|
||||||
return <LandingPage content={content} />
|
return <LandingPage content={content} />
|
||||||
@@ -2296,7 +2115,6 @@ export default function App() {
|
|||||||
'#qa': '/questions',
|
'#qa': '/questions',
|
||||||
'#contact': '/contact',
|
'#contact': '/contact',
|
||||||
'#series': '/resources',
|
'#series': '/resources',
|
||||||
'#archives': '/resources',
|
|
||||||
'#resources': '/resources',
|
'#resources': '/resources',
|
||||||
'#start-here': '/start-here',
|
'#start-here': '/start-here',
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user