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.
+