From 27656b84f274d399acb9a96fff0cc5c5d7e47fb8 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Tue, 13 Oct 2026 15:36:37 -0400 Subject: [PATCH] Gospel courtroom page: tabs, plea dropdown, plea email, Acts, accuser/charges, site nav and links; fix service worker for /salvation; courtroom contact type; accept community type; v2.39.0 Co-Authored-By: Claude Sonnet 5.5 --- package.json | 2 +- public/salvation/index.html | 769 ++++++++++++++++++++++++++++++++- server.js | 2 + server/data.js | 2 +- server/routes/public.js | 4 +- server/routes/salvation.js | 92 ++++ src/App.css | 7 + src/App.tsx | 11 + src/InboxPanel.tsx | 1 + src/components/ContactForm.tsx | 3 +- vite.config.ts | 4 +- 11 files changed, 875 insertions(+), 22 deletions(-) create mode 100644 server/routes/salvation.js diff --git a/package.json b/package.json index 90ae037..cb85c12 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.38.0", + "version": "2.39.0", "type": "module", "scripts": { "dev": "vite", diff --git a/public/salvation/index.html b/public/salvation/index.html index ff48184..c3dc3ba 100644 --- a/public/salvation/index.html +++ b/public/salvation/index.html @@ -3,7 +3,17 @@ -Gospel Harmony — The Trial & Crucifixion + + + + + + + + + + +The Gospel, in a Courtroom | Verse by Verse with Nate + +
-
+
-

A Gospel Harmony
The Trial & Crucifixion of Jesus

-

Matthew · Mark · Luke · John — Woven into One Timeline

-
+

The Court of Heaven Is in Session

+

The Gospel, in a Courtroom

+

God is the Judge. Satan is the accuser. The charges are your sins, and you are the one on trial. But a Substitute has stepped forward to take your place. Hear the case, read the record, and enter your plea.

+ +

“Therefore, there is now no condemnation for those who are in Christ Jesus.” Romans 8:1

+
+
+ + +
+ +
+
+ +

The Case and the Good News

+

Every one of us will stand before God. The gospel is the news of how a guilty person can walk out of that courtroom free. Follow the case from the accuser’s first charge to the final pardon.

+
    +
  1. I · The Judge + God is perfectly just. He will not overlook wrongdoing. Nothing is hidden from him, and he judges rightly. + “Will not the Judge of all the earth do right?” Genesis 18:25 + “For He has set a day when He will judge the world with justice by the Man He has appointed.” Acts 17:31
  2. +
  3. II · The Accuser + Every courtroom has an accuser, and ours is Satan. His name means “adversary.” Scripture calls him the accuser of the brothers, who brings charges against us before God. He does not have to invent anything. He only has to point to what we have actually done. + “...the accuser of our brothers, who accuses them before our God day and night.” Revelation 12:10 + “Your adversary the devil prowls around like a roaring lion, looking for someone to devour.” 1 Peter 5:8
  4. +
  5. III · The Charges + The charges are the sins you have committed. + Sin is breaking God’s law. That includes the wrong things we knowingly do, the good things we fail to do, and the things we say and think in secret. + It also includes breaking his law without knowing it. Not knowing the law has never made anyone innocent. God’s standard is written on every conscience, so no one is without excuse (Romans 1:20; 2:14–15), and even sin done unknowingly still leaves a person guilty (Leviticus 5:17). + And God does not grade on a curve. Stumbling at one point means you have broken the whole law. We have all sinned. That includes you and me. + “Everyone who practices sin practices lawlessness; indeed, sin is lawlessness.” 1 John 3:4 + “For whoever keeps the whole law but stumbles at just one point is guilty of breaking all of it.” James 2:10 + “There is no one righteous, not even one.” Romans 3:10 + “For all have sinned and fall short of the glory of God.” Romans 3:23
  6. +
  7. IV · The Verdict + The evidence is clear, and there is no defense. We cannot argue our way out, and our good deeds cannot cancel our guilt. The sentence for sin is death, separation from the God who made us. + “...so that every mouth may be silenced and the whole world held accountable to God.” Romans 3:19 + “For the wages of sin is death...” Romans 6:23
  8. +
  9. V · The Substitute + Then God himself stepped forward to take your place. + Jesus is God. The eternal Son took on flesh and was born as a baby in Bethlehem, and he came for one purpose: to take the place of sinners like you and me. He did not come to be served. He came to give his life, and the cross was always where the road was heading. + He lived the perfect life you could not live. On the cross he took the sentence that belonged to you, and he rose again on the third day to prove the payment was accepted. + The story below walks through his arrest, his trials, his crucifixion and his resurrection. + “The Word became flesh and dwelt among us.” John 1:14 + “She will bear a Son, and you are to name Him Jesus, because He will save His people from their sins.” Matthew 1:21 + “For even the Son of Man did not come to be served, but to serve, and to give His life as a ransom for many.” Mark 10:45 + “For Christ also suffered once for sins, the righteous for the unrighteous, to bring you to God.” 1 Peter 3:18 + “God made Him who knew no sin to be sin for us, so that in Him we might become the righteousness of God.” 2 Corinthians 5:21 + “But God proves His love for us in this: While we were still sinners, Christ died for us.” Romans 5:8
  10. +
  11. VI · The Pardon + The pardon is a gift. It cannot be earned or bought. You receive it by turning from your sin and trusting Jesus alone. When you do, God declares you forgiven and counts you righteous because of what Christ has done. + “For by grace you have been saved through faith, and this is not from yourselves, but it is the gift of God, not by works, so that no one can boast.” Ephesians 2:8–9 + “If you confess with your mouth that Jesus is Lord, and believe in your heart that God raised Him from the dead, you will be saved.” Romans 10:9
  12. +
  13. VII · The Result + Case closed. The accuser has nothing left to say. Our sins were real charges, but they have been answered, because the penalty has been paid in full by the Substitute. The one who trusts in Christ is not waiting nervously for a verdict. The verdict has already been given in their favor. + “Therefore, there is now no condemnation for those who are in Christ Jesus.” Romans 8:1 + “Who can bring an accusation against those whom God has chosen? It is God who justifies. Who then can condemn? Christ Jesus, who died—more than that, was raised to life—is at the right hand of God, and He is interceding for us.” Romans 8:33–34
  14. +
+
+ + + +
+

VIII · Your Plea

+

How Do You Plead?

+

The court is not asking whether you are a good person. It is asking you to be honest about the charges and to decide where you will stand: on your own record, or on what Jesus has done in your place. Choose your plea below. Have a question first? Ask a question about the courtroom.

+ +
+ + +
+ +
+
+

Guilty

+

What it means: I admit it. I have broken God’s law, whether I knew it or not, and I cannot save myself. I am not asking the court to find me innocent. I am asking for the pardon Jesus paid for with his life.

+

What happens: The Judge declares you forgiven and counts you righteous because of Christ. The accuser has nothing left to say.

+
+
+

Not Guilty

+

What it means: I stand on my own record. I believe I have kept God’s law well enough to be found innocent, and I do not need anyone to take my place.

+

What happens: You are judged by God’s perfect standard, with no Substitute. Be sure you understand what that means before you stand on it.

+
+
+ + + + + + +
+
+ + + + diff --git a/server.js b/server.js index f28862f..ce77bf1 100644 --- a/server.js +++ b/server.js @@ -76,6 +76,7 @@ import { register as registerFeed } from './server/routes/feed.js' import { register as registerQrCodes } from './server/routes/qr-codes.js' import { register as registerBiblePassage } from './server/routes/bible-passage.js' import { register as registerNewsletter } from './server/routes/newsletter.js' +import { register as registerSalvation } from './server/routes/salvation.js' import { register as registerPublic } from './server/routes/public.js' preferIpv4ForSeafile() @@ -136,6 +137,7 @@ registerFeed(app) registerQrCodes(app) registerBiblePassage(app) registerNewsletter(app) +registerSalvation(app) // Hit-counting middleware (must come before public routes) app.use((req, res, next) => { diff --git a/server/data.js b/server/data.js index eda49cd..3203f22 100644 --- a/server/data.js +++ b/server/data.js @@ -1166,7 +1166,7 @@ export async function restoreFromBackup(filename) { // (These are defined here rather than study-helpers to avoid circular imports) export function normalizeMessageType(value) { - if (value === 'question' || value === 'testimony' || value === 'topic') return value + if (value === 'question' || value === 'testimony' || value === 'topic' || value === 'gospel' || value === 'community') return value return 'general' } diff --git a/server/routes/public.js b/server/routes/public.js index c255ab5..1153e5a 100644 --- a/server/routes/public.js +++ b/server/routes/public.js @@ -67,7 +67,7 @@ export function register(app) { const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical res.type('text/plain').send( - ['User-agent: *', 'Allow: /', 'Disallow: /salvation', `Sitemap: ${root}/sitemap.xml`].join('\n'), + ['User-agent: *', 'Allow: /', `Sitemap: ${root}/sitemap.xml`].join('\n'), ) }) @@ -111,6 +111,8 @@ export function register(app) { for (const ep of highlights) { if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`) } + // The standalone gospel page + if (!urls.includes(`${root}/salvation`)) urls.push(`${root}/salvation`) // Newsletter archive + published issues urls.push(`${root}/newsletter`) for (const issue of state.newsletterIssues) { diff --git a/server/routes/salvation.js b/server/routes/salvation.js new file mode 100644 index 0000000..a029aec --- /dev/null +++ b/server/routes/salvation.js @@ -0,0 +1,92 @@ +import { Resend } from 'resend' +import { escapeHtml } from '../helpers.js' +import { getResendFromAddress, getResendInboxAddress, sendResendEmailWithRetry } from '../email.js' + +// A visitor who finished the /salvation page can tell us they prayed to receive Christ. +// Nothing is stored on the server: the response is emailed once to the site inbox and that is all. +const hits = new Map() + +function rateLimit(req, res, next) { + const ip = req.ip ?? 'unknown' + const now = Date.now() + const windowMs = 10 * 60 * 1000 + const entry = hits.get(ip) ?? { count: 0, start: now } + if (now - entry.start > windowMs) { entry.count = 0; entry.start = now } + entry.count += 1 + hits.set(ip, entry) + if (hits.size > 2000) { + for (const [k, v] of hits) { if (now - v.start > windowMs) hits.delete(k) } + } + if (entry.count > 3) { + res.status(429).json({ message: 'Please wait a few minutes before trying again.' }) + return + } + next() +} + +function clean(value, max) { + return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ').slice(0, max) : '' +} + +export function register(app) { + app.post('/api/salvation-response', rateLimit, async (req, res) => { + const { name, email, message, _honey } = req.body ?? {} + if (_honey) { res.json({ ok: true }); return } + + const safeName = clean(name, 100) + const safeEmail = clean(email, 200) + const safeMessage = typeof message === 'string' ? message.trim().slice(0, 2000) : '' + if (safeEmail && !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(safeEmail)) { + res.status(400).json({ message: 'That email address does not look right. You can also leave it blank.' }) + return + } + + if (!process.env.RESEND_API_KEY) { + console.error('[salvation] RESEND_API_KEY env var not set') + res.status(503).json({ message: 'We could not send this right now.' }) + return + } + + const when = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }) + const nameLine = safeName || 'Name not given' + const emailLine = safeEmail || 'Email not given' + const text = [ + 'Someone responded to the gospel on the /salvation page (the courtroom presentation) and says they pleaded guilty, accepted the pardon, and prayed to receive Jesus as Savior.', + '', + `Name: ${nameLine}`, + `Email: ${emailLine}`, + `When: ${when}`, + '', + safeMessage ? `Their message:\n${safeMessage}` : 'They did not leave a message.', + '', + safeEmail ? 'You can reply directly to this email to reach them.' : 'They did not leave an email address, so there is no way to reply.', + ].join('\n') + const html = `
+

Someone responded to the gospel on the /salvation page and says they prayed to receive Jesus as Savior.

+

Name: ${escapeHtml(nameLine)}
Email: ${escapeHtml(emailLine)}
When: ${escapeHtml(when)}

+${safeMessage ? `

Their message:

${escapeHtml(safeMessage).replace(/\n/g, '
')}
` : '

They did not leave a message.

'} +

${safeEmail ? 'You can reply directly to this email to reach them.' : 'They did not leave an email address, so there is no way to reply.'}

+
` + + try { + const resend = new Resend(process.env.RESEND_API_KEY) + await sendResendEmailWithRetry({ + resend, + context: 'salvation-response', + payload: { + from: getResendFromAddress(), + to: [getResendInboxAddress()], + ...(safeEmail ? { replyTo: safeEmail } : {}), + subject: 'Someone prayed to receive Christ on your salvation page', + tags: [{ name: 'flow', value: 'salvation-response' }], + text, + html, + }, + }) + res.json({ ok: true }) + } catch (err) { + console.error('[salvation] email failed:', err?.message ?? err) + res.status(502).json({ message: 'We could not send this right now.' }) + } + }) +} diff --git a/src/App.css b/src/App.css index 7147eaa..0c4b034 100644 --- a/src/App.css +++ b/src/App.css @@ -14150,3 +14150,10 @@ a.sl-pager-link:hover { border-color: rgba(201, 168, 76, 0.6); transform: transl #root .ed-side .ed-side-heading { font-size: 0.75rem; letter-spacing: 0.14em; text-transform: uppercase; color: #c9a84c; margin: 0 0 0.8rem; font-family: inherit; font-weight: 600; line-height: 1.2; } .auth-card--center .study-signup-form { text-align: left; } .cp-main--bare { min-height: 100vh; align-items: center; } + +/* ── Start Here: gospel callout ── */ +.start-gospel { max-width: 760px; margin: 2.5rem auto 0; padding: 1.75rem 1.9rem; text-align: center; background: linear-gradient(145deg, #17170f, #0c0c08); border: 1px solid rgba(201, 168, 76, 0.35); border-radius: 16px; } +.start-gospel h2 { font-family: var(--brand-font-heading); font-size: 1.7rem; color: #f0e9cc; margin: 0.3rem 0 0.7rem; } +.start-gospel p { color: #a89a6a; line-height: 1.7; margin: 0 auto 1.2rem; max-width: 56ch; } +.start-gospel .eyebrow { margin-bottom: 0; color: #c9a84c; } +.header-nav-link { white-space: nowrap; } diff --git a/src/App.tsx b/src/App.tsx index 925797a..f89e90b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -503,6 +503,8 @@ function SiteHeader({ content }: { content: SiteContent }) {
`header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Resources + {/* /salvation is a standalone static page, so it needs a real page load rather than a router link */} + setMenuOpen(false)}>The Gospel `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact