From 9f52437a074432aecf8d9a1e326b74cf25d372ad Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 12 Oct 2026 16:51:51 -0400 Subject: [PATCH] =?UTF-8?q?Ask=20for=20name=20and=20email=20before=20sendi?= =?UTF-8?q?ng=20visitors=20to=20Amazon=20or=20Apple=20Books=20(switchable?= =?UTF-8?q?=20in=20admin=20=E2=86=92=20Books);=20v2.29.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 3 + package-lock.json | 4 +- package.json | 2 +- server/routes/downloads.js | 75 +++++++++++++++++++ src/AdminPage.tsx | 14 ++++ src/App.css | 28 +++++++ src/App.tsx | 9 ++- src/components/ResourcesHub.tsx | 23 +++++- src/components/StoreLink.tsx | 128 ++++++++++++++++++++++++++++++++ src/content.ts | 2 + 10 files changed, 277 insertions(+), 11 deletions(-) create mode 100644 src/components/StoreLink.tsx diff --git a/.gitignore b/.gitignore index d52a517..38bc024 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ data/episode-plays.json # Episode workflow progress — lives only in the server data volume data/podcast-workflow.json data/nuggets-workflow.json + +# Live download counters — server data volume only +data/download-counts.json diff --git a/package-lock.json b/package-lock.json index 911e972..788d5df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siteforge", - "version": "2.28.0", + "version": "2.29.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siteforge", - "version": "2.28.0", + "version": "2.29.0", "dependencies": { "@aws-sdk/client-s3": "^3.1135.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index e27ec46..3f4be32 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.28.0", + "version": "2.29.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server/routes/downloads.js b/server/routes/downloads.js index 7cea3a1..6750a9c 100644 --- a/server/routes/downloads.js +++ b/server/routes/downloads.js @@ -222,6 +222,81 @@ export function register(app) { } }) + // Collects a visitor's details, then hands back the Amazon / Apple Books link for a guide or book. + // The destination always comes from the saved site content, never from the request. + app.post('/api/store-link', studyDownloadRateLimit, async (req, res) => { + try { + const { source, store, firstName, lastName, email, subscribe, _honey } = req.body ?? {} + if (_honey) { res.json({ ok: true }); return } + + if (typeof source !== 'string' || !source || (store !== 'amazon' && store !== 'apple')) { + res.status(400).json({ message: 'That link is not available.' }); return + } + if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) { + res.status(400).json({ message: 'First name is required.' }); return + } + if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) { + res.status(400).json({ message: 'Last name is required.' }); return + } + if (!email || typeof email !== 'string' || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) { + res.status(400).json({ message: 'A valid email address is required.' }); return + } + + const published = await loadSiteContentFile(DATA_FILE) + const content = published?.siteContent ?? {} + const pick = (amazon, apple) => (store === 'amazon' ? amazon : apple) + let label = '' + let url = '' + + if (source === 'page:primary-guide') { + label = content.studyGuideTitle + url = pick(content.studyGuideUrl, content.studyGuideAppleBooksUrl) + } else if (source === 'page:second-guide') { + const guide = content.secondSeries?.guide + label = guide?.title + url = pick(guide?.amazonUrl, guide?.appleBooksUrl) + } else if (source.startsWith('page:book-guide--')) { + const id = source.slice('page:book-guide--'.length) + const guide = (Array.isArray(content.finishedBooks) ? content.finishedBooks : []).find(b => b?.id === id)?.guide + label = guide?.title + url = pick(guide?.amazonUrl, guide?.appleBooksUrl) + } else if (source.startsWith('page:custom--')) { + const id = source.slice('page:custom--'.length) + const link = (content.customLinks ?? []).find(l => l?.id === id && l.placement === 'resources') + label = link?.label + url = pick(link?.amazonUrl, link?.appleBooksUrl) + } else if (source.startsWith('book:')) { + const id = source.slice('book:'.length) + const book = (content.customLinks ?? []).find(l => l?.id === id && l.placement === 'books') + label = book?.label + url = pick(book?.amazonUrl || book?.url, book?.appleBooksUrl) + } + + const destination = sanitizeUrl(url) + if (!destination) { res.status(404).json({ message: 'That link is not available.' }); return } + + const name = `${firstName.trim()} ${lastName.trim()}`.trim() + const trimmedEmail = email.trim() + const wantsSubscribe = subscribe !== false + const storeName = store === 'amazon' ? 'Amazon' : 'Apple Books' + + addContactSubmission({ + name, + email: trimmedEmail, + message: `Requested ${storeName} link: ${label || source}`, + messageType: 'general', + subscribe: wantsSubscribe, + }) + if (wantsSubscribe) await syncContactToResend(name, trimmedEmail) + incrementDownloadCount(`store:${source}:${store}`) + + res.json({ ok: true, url: destination }) + } catch (err) { + console.error('[store-link] request error:', err) + res.status(500).json({ message: 'Failed to process your request. Please try again.' }) + } + }) + app.get('/api/admin-download-stats', requireAdminAuth, (_req, res) => { res.json({ counts: state.downloadCounts }) }) diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index dc72156..ad2a768 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -5801,6 +5801,20 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())

Books

Amazon books shown in the Reading List section on the Resources page. Add a cover image, description, and Amazon link for each book.

+
+ +

+ Applies to the Amazon and Apple Books buttons on the Reading List and on the study guide and download pages. Each visitor is added to your Inbox as a contact, and subscribed if they leave the box ticked. Turn it off to send visitors straight to the store. +

+
{(form.customLinks ?? []).filter(l => l.placement === 'books').length === 0 &&

No books yet.

} {(form.customLinks ?? []).filter(l => l.placement === 'books').map(book => (
diff --git a/src/App.css b/src/App.css index a21ba7b..c5d8c2b 100644 --- a/src/App.css +++ b/src/App.css @@ -13704,3 +13704,31 @@ a.study-track-card:hover { .cx-support { padding-bottom: 2rem; } } +/* ── Store link sign-up dialog (before Amazon / Apple Books) ─────────────── */ +.sl-overlay { position: fixed; inset: 0; z-index: 400; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(0, 0, 0, 0.7); } +.sl-dialog { + width: min(460px, 100%); max-height: 100%; overflow-y: auto; padding: 1.5rem; + background: var(--brand-black-3); border: 1px solid var(--brand-gold-dark); border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); +} +#root > :not(.admin-shell) .sl-dialog h3 { margin: 0 0 0.4rem; font-family: var(--brand-font-heading); font-size: 1.6rem; color: var(--brand-warm-white); } +.sl-lead { margin: 0 0 1rem; font-size: 0.95rem; line-height: 1.55; color: var(--brand-muted); } +.sl-dialog form { display: flex; flex-direction: column; gap: 0.85rem; } +.sl-dialog label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.85rem; font-weight: 500; color: var(--brand-muted); } +.sl-dialog input[type='text'], .sl-dialog input[type='email'] { + font: inherit; font-size: 1rem; color: var(--brand-warm-white); background: var(--brand-black); width: 100%; min-width: 0; + border: 1px solid var(--brand-border); border-radius: 8px; padding: 0.65rem 0.8rem; +} +.sl-dialog input:focus { outline: none; border-color: var(--brand-gold); box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.16); } +.sl-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0.75rem; } +.sl-dialog label.sl-check { flex-direction: row; align-items: flex-start; gap: 0.55rem; font-size: 0.88rem; font-weight: 400; cursor: pointer; } +.sl-check input { margin-top: 0.22rem; flex-shrink: 0; accent-color: var(--brand-gold); } +.sl-error { margin: 0; font-size: 0.9rem; color: #e07a6a; } +.sl-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 0.3rem; } +.sl-actions .btn-primary { flex: 1 1 auto; justify-content: center; } +@media (max-width: 480px) { + .sl-overlay { align-items: flex-end; padding: 0; } + .sl-dialog { border-radius: 16px 16px 0 0; padding: 1.25rem; } + .sl-row { grid-template-columns: minmax(0, 1fr); } + .sl-actions .btn-secondary { flex: 1 1 100%; justify-content: center; } +} + diff --git a/src/App.tsx b/src/App.tsx index 391df39..4db25c4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer' import { NuggetsHostPortal } from './components/NuggetsHostPortal' import { HeaderAccount } from './components/HeaderAccount' import { ResourcesHub } from './components/ResourcesHub' +import { StoreLink } from './components/StoreLink' import { BIBLE_BOOKS } from '../shared/bible-books.js' import './App.css' import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking, checkAnalyticsConsentResponse } from './analytics' @@ -933,14 +934,14 @@ function DownloadDetailPage({ content }: { content: SiteContent }) {
Or get it from: {resource.amazonUrl && ( - + {resource.amazonLabel || 'Get it on Amazon'} - + )} {resource.appleBooksUrl && ( - + {resource.appleBooksLabel || 'Get it on Apple Books'} - + )}
)} diff --git a/src/components/ResourcesHub.tsx b/src/components/ResourcesHub.tsx index 9daaaeb..daca8cc 100644 --- a/src/components/ResourcesHub.tsx +++ b/src/components/ResourcesHub.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import type { ReactNode } from 'react' import { Link } from 'react-router-dom' import type { SiteContent } from '../content' +import { StoreLink } from './StoreLink' type TabKey = 'downloads' | 'books' | 'recommended' @@ -35,6 +36,8 @@ function ClampedText({ text, className, lines = 3, threshold = 150 }: { text: st export function ResourcesHub({ content, downloadPath, fallback }: Props) { const links = content.customLinks ?? [] + // Ask for name and email before sending visitors to Amazon / Apple Books (on unless switched off in the admin) + const gated = content.gateStoreLinks !== false const resources = links.filter(l => l.placement === 'resources') const books = links.filter(l => l.placement === 'books') const recommended = links.filter(l => l.placement === 'recommended') @@ -174,14 +177,26 @@ export function ResourcesHub({ content, downloadPath, fallback }: Props) { {book.description && }
{(book.amazonUrl || book.url) && ( - + {book.amazonUrl ? (book.amazonLabel || 'View on Amazon') : (book.amazonLabel || 'Learn More')} - + )} {book.appleBooksUrl && ( - + {book.appleBooksLabel || 'Apple Books'} - + )}
diff --git a/src/components/StoreLink.tsx b/src/components/StoreLink.tsx new file mode 100644 index 0000000..a895068 --- /dev/null +++ b/src/components/StoreLink.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef, useState } from 'react' +import type { ReactNode } from 'react' + +const SAVED_KEY = 'vbn-store-link-contact' + +interface Saved { firstName: string; lastName: string; email: string } + +function readSaved(): Saved { + try { + const raw = localStorage.getItem(SAVED_KEY) + if (raw) { + const d = JSON.parse(raw) as Partial + return { firstName: d.firstName ?? '', lastName: d.lastName ?? '', email: d.email ?? '' } + } + } catch { /* private mode or bad data */ } + return { firstName: '', lastName: '', email: '' } +} + +interface Props { + // Which guide or book this link belongs to (the server finds the real address from this) + source: string + store: 'amazon' | 'apple' + // The address used when the sign-up step is switched off + href: string + className?: string + children: ReactNode + gated: boolean +} + +// A button that asks for name and email, then sends the visitor on to Amazon or Apple Books. +// With the gate switched off it is just a normal link. +export function StoreLink({ source, store, href, className, children, gated }: Props) { + const [open, setOpen] = useState(false) + const [fields, setFields] = useState({ firstName: '', lastName: '', email: '' }) + const [subscribe, setSubscribe] = useState(true) + const [honey, setHoney] = useState('') + const [status, setStatus] = useState<'idle' | 'sending' | 'error'>('idle') + const [error, setError] = useState('') + const firstRef = useRef(null) + const storeName = store === 'amazon' ? 'Amazon' : 'Apple Books' + + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('keydown', onKey) + const t = window.setTimeout(() => firstRef.current?.focus(), 30) + return () => { document.removeEventListener('keydown', onKey); window.clearTimeout(t) } + }, [open]) + + if (!gated) { + return {children} + } + + const openDialog = () => { + setFields(readSaved()) + setStatus('idle') + setError('') + setOpen(true) + } + + async function submit(e: React.FormEvent) { + e.preventDefault() + setStatus('sending') + setError('') + try { + const res = await fetch('/api/store-link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source, store, ...fields, subscribe, _honey: honey }), + }) + const data = (await res.json().catch(() => ({}))) as { url?: string; message?: string } + if (!res.ok || !data.url) { + setError(data.message ?? 'Something went wrong. Please try again.') + setStatus('error') + return + } + try { localStorage.setItem(SAVED_KEY, JSON.stringify(fields)) } catch { /* ignore */ } + window.location.assign(data.url) + } catch { + setError('Could not connect. Please try again later.') + setStatus('error') + } + } + + const set = (name: keyof Saved) => (e: React.ChangeEvent) => setFields(f => ({ ...f, [name]: e.target.value })) + + return ( + <> + + {open && ( +
{ if (e.target === e.currentTarget) setOpen(false) }}> +
+

One quick step

+

Tell us where to send updates, then we'll take you straight to {storeName}.

+
+ setHoney(e.target.value)} /> +
+ + +
+ + + {status === 'error' &&

{error}

} +
+ + +
+
+
+
+ )} + + ) +} diff --git a/src/content.ts b/src/content.ts index 4b0cf61..5eeae40 100644 --- a/src/content.ts +++ b/src/content.ts @@ -396,6 +396,8 @@ export interface SiteContent { qaAnsweredEmailSignoff: string // ── Header ── headerFollowLabel: string + // Ask for name and email before sending visitors to Amazon or Apple Books (default on) + gateStoreLinks?: boolean // ── Cookie banner ── cookieBannerText: string // ── Arrays ──