From 5bc8245ed244e8b553b3b7b680cc67477fb9a28b Mon Sep 17 00:00:00 2001
From: nmemmert
Date: Thu, 3 Sep 2026 14:17:47 -0400
Subject: [PATCH 1/6] Add structured show notes form to Episode Highlights;
v1.2.3
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace single showNotes textarea with five guided fields: Episode Summary,
Key Themes, Scripture References, Key Quotes, and Resources & Links. Legacy
showNotes content is preserved as a backward-compatible fallback — it
auto-populates the Summary field in the admin editor and renders on the public
episode detail page until overwritten with structured data.
Co-Authored-By: Claude Sonnet 4.6
---
package.json | 2 +-
src/AdminPage.tsx | 66 ++++++++++++++++++++++++++++++++++++++++++++---
src/App.tsx | 44 +++++++++++++++++++++++++++++--
src/content.ts | 7 +++++
4 files changed, 113 insertions(+), 6 deletions(-)
diff --git a/package.json b/package.json
index 67cea13..15b450b 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
- "version": "1.2.2",
+ "version": "1.2.3",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx
index a6805a1..83cf05b 100644
--- a/src/AdminPage.tsx
+++ b/src/AdminPage.tsx
@@ -1642,7 +1642,11 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
summary: '',
url: '',
embedUrl: '',
- showNotes: '',
+ showNotesSummary: '',
+ showNotesThemes: [],
+ showNotesScripture: [],
+ showNotesQuotes: [],
+ showNotesResources: [],
discussionQuestions: [],
},
],
@@ -1656,6 +1660,16 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
}))
}
+ function updatePodcastLinkStringArray(id: string, field: keyof PodcastFeaturedLink, raw: string) {
+ const items = raw.split('\n').map(s => s.trim()).filter(Boolean)
+ setForm(f => ({
+ ...f,
+ podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link =>
+ link.id === id ? { ...link, [field]: items } : link
+ ),
+ }))
+ }
+
function updatePodcastLinkQuestions(id: string, raw: string) {
const questions = raw.split('\n').map(q => q.trim()).filter(Boolean)
setForm(f => ({
@@ -3440,8 +3454,54 @@ async function handleAssetUpload(event: ChangeEvent) {
- Show Notes
-
+
+ Key Themes (one per line)
+ updatePodcastLinkStringArray(activeHighlight.id, 'showNotesThemes', e.target.value)}
+ />
+
+
+ Scripture References (one per line)
+ updatePodcastLinkStringArray(activeHighlight.id, 'showNotesScripture', e.target.value)}
+ />
+
+
+ Key Quotes (one per line)
+ updatePodcastLinkStringArray(activeHighlight.id, 'showNotesQuotes', e.target.value)}
+ />
+
+
+ Resources & Links (one per line)
+ updatePodcastLinkStringArray(activeHighlight.id, 'showNotesResources', e.target.value)}
+ />
Discussion Questions (one per line)
diff --git a/src/App.tsx b/src/App.tsx
index 7c32a91..7904cca 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -716,6 +716,11 @@ function PodcastHighlightsSection({ content }: { content: SiteContent }) {
|| isLikelySpotifyEpisodeUrl(link.url)
|| (link.discussionQuestions ?? []).length > 0
|| link.showNotes
+ || link.showNotesSummary
+ || (link.showNotesThemes ?? []).length > 0
+ || (link.showNotesScripture ?? []).length > 0
+ || (link.showNotesQuotes ?? []).length > 0
+ || (link.showNotesResources ?? []).length > 0
)
const dest = (!hasRichContent && link.url)
? { external: link.url }
@@ -1500,10 +1505,45 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
)}
- {episode.showNotes && (
+ {(episode.showNotesSummary || (episode.showNotesThemes ?? []).length > 0 || (episode.showNotesScripture ?? []).length > 0 || (episode.showNotesQuotes ?? []).length > 0 || (episode.showNotesResources ?? []).length > 0 || episode.showNotes) && (
Show Notes
-
{episode.showNotes}
+ {episode.showNotesSummary
+ ?
{episode.showNotesSummary}
+ : episode.showNotes && !(episode.showNotesThemes ?? []).length &&
{episode.showNotes}
+ }
+ {(episode.showNotesThemes ?? []).length > 0 && (
+
+
Key Themes
+
+ {episode.showNotesThemes!.map((t, i) => {t} )}
+
+
+ )}
+ {(episode.showNotesScripture ?? []).length > 0 && (
+
+
Scripture References
+
+ {episode.showNotesScripture!.map((s, i) => {s} )}
+
+
+ )}
+ {(episode.showNotesQuotes ?? []).length > 0 && (
+
+
Key Quotes
+ {episode.showNotesQuotes!.map((q, i) => (
+
{q}
+ ))}
+
+ )}
+ {(episode.showNotesResources ?? []).length > 0 && (
+
+
Resources
+
+ {episode.showNotesResources!.map((r, i) => {r} )}
+
+
+ )}
)}
diff --git a/src/content.ts b/src/content.ts
index fc6ace5..fc48cd2 100644
--- a/src/content.ts
+++ b/src/content.ts
@@ -67,6 +67,13 @@ export interface PodcastFeaturedLink {
summary: string
url: string
embedUrl?: string
+ // Structured show notes fields
+ showNotesSummary?: string
+ showNotesThemes?: string[]
+ showNotesScripture?: string[]
+ showNotesQuotes?: string[]
+ showNotesResources?: string[]
+ // Legacy plain-text field — kept for backward compat with live data
showNotes?: string
discussionQuestions?: string[]
}
From 038ef2b039056c079afb4c3f23288c5d1d8e4bd9 Mon Sep 17 00:00:00 2001
From: nmemmert
Date: Thu, 3 Sep 2026 15:16:47 -0400
Subject: [PATCH 2/6] Add study account engagement features; v1.2.4
- Pause reminders: remindersPausedUntil field + scheduler skip + account UI with 7/14/30-day presets
- Email digest mode: reminderFrequency field (immediate/weekly) + weekly digest email + scheduler + account UI toggle
- Admin user list improvements: streak, reengagement, frequency, pause, per-study lesson progress
- Admin manual re-engagement: POST endpoint + tier selector button in admin panel
- Study bookmarks: bookmarkedSectionIds in progress file + POST/DELETE endpoints + toggle button in lesson UI
- Study completion certificate email: auto-sent on first certificate issuance
- Content fields for certificate and weekly digest email templates in admin
Co-Authored-By: Claude Sonnet 4.6
---
package.json | 2 +-
server.js | 11 +-
server/data.js | 7 ++
server/email.js | 77 ++++++++++++
server/routes/admin-assets.js | 53 +++++++-
server/routes/study-account.js | 24 +++-
server/routes/study-certificate.js | 5 +
server/routes/study-data.js | 42 ++++++-
server/study-helpers.js | 81 +++++++++++-
src/AdminPage.tsx | 64 +++++++++-
src/colossiansStudy.tsx | 196 ++++++++++++++++++++++++-----
src/content.ts | 16 +++
12 files changed, 540 insertions(+), 38 deletions(-)
diff --git a/package.json b/package.json
index 15b450b..9dad657 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
- "version": "1.2.3",
+ "version": "1.2.4",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/server.js b/server.js
index 6536faa..6ac0d88 100644
--- a/server.js
+++ b/server.js
@@ -31,7 +31,7 @@ import {
refreshContentCaches,
queueHitStatsWrite,
} from './server/data.js'
-import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail } from './server/email.js'
+import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail, sendStudyWeeklyDigestEmail } from './server/email.js'
import {
detectBot,
sanitizeUserAgent,
@@ -39,6 +39,7 @@ import {
recordHit,
scheduleStudyReminders,
scheduleReengagementEmails,
+ scheduleWeeklyDigestEmails,
} from './server/study-helpers.js'
import { recordVisitor } from './server/routes/analytics.js'
@@ -194,6 +195,14 @@ Promise.all([
}, 60 * 60 * 1000)
void scheduleReengagementEmails(sendStudyReengagementEmail)
+ // Send weekly lesson digest emails every hour (only actually fires every 7 days per user)
+ setInterval(() => {
+ scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail).catch(err => {
+ console.error('[study-digest] failed to schedule weekly digest:', err)
+ })
+ }, 60 * 60 * 1000)
+ void scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail)
+
app.listen(PORT, () => {
logResendEmailAlignmentWarnings()
console.log(`Siteforge listening on http://localhost:${PORT}`)
diff --git a/server/data.js b/server/data.js
index 40290d1..542f391 100644
--- a/server/data.js
+++ b/server/data.js
@@ -1415,6 +1415,9 @@ export function sanitizeStudyUsers(value) {
totpEnabledAt: typeof item?.totpEnabledAt === 'string' ? item.totpEnabledAt : null,
totpRecoveryCodes: Array.isArray(item?.totpRecoveryCodes) ? item.totpRecoveryCodes.filter(h => typeof h === 'string') : [],
totpSecretPending: typeof item?.totpSecretPending === 'string' ? item.totpSecretPending : undefined,
+ remindersPausedUntil: typeof item?.remindersPausedUntil === 'string' ? item.remindersPausedUntil : null,
+ reminderFrequency: item?.reminderFrequency === 'weekly' ? 'weekly' : 'immediate',
+ weeklyDigestSentAt: typeof item?.weeklyDigestSentAt === 'string' ? item.weeklyDigestSentAt : null,
})
}
return out.slice(0, MAX_STUDY_USERS)
@@ -1459,8 +1462,12 @@ export function sanitizeStudyProgress(value) {
])
)
: {}
+ const bookmarkedSectionIds = Array.isArray(studyData?.bookmarkedSectionIds)
+ ? Array.from(new Set(studyData.bookmarkedSectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim())))
+ : []
progress.byStudy[studySlug.trim().toLowerCase()] = {
completedSectionIds: Array.from(new Set(completedSectionIds)),
+ bookmarkedSectionIds,
quizAnswers,
}
}
diff --git a/server/email.js b/server/email.js
index 471756c..3b482bf 100644
--- a/server/email.js
+++ b/server/email.js
@@ -635,3 +635,80 @@ export async function sendStudyReengagementEmail(email, displayName, studyTitle,
console.error('[study-reengagement] send exception:', err)
}
}
+
+export async function sendStudyCertificateEmail(email, displayName, studyTitle, certUrl) {
+ if (!process.env.RESEND_API_KEY) return
+ try {
+ const resend = new Resend(process.env.RESEND_API_KEY)
+ const cfg = state.cachedSiteContent ?? {}
+ const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
+ const subject = cfg.studyCertificateEmailSubject?.trim() || 'Your certificate is ready'
+ const bodyText = cfg.studyCertificateEmailBody?.trim() || 'Congratulations — you have completed every lesson in this study. Your certificate is ready to view and share.'
+ const ctaLabel = cfg.studyCertificateEmailCtaLabel?.trim() || 'View Certificate'
+ const signoff = cfg.studyCertificateEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate'
+ const bodyHtml = (
+ `Congratulations, ${escapeHtml(namePart)} !
` +
+ `${escapeHtml(bodyText)}
` +
+ `${escapeHtml(studyTitle)}
`
+ )
+ const footerHtml = `${escapeHtml(signoff).replace(/\n/g, ' ')}
`
+ const { error } = await resend.emails.send({
+ from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
+ to: [email],
+ subject,
+ text: `Congratulations, ${namePart}!\n\n${bodyText}\n\n${studyTitle}\n\nView your certificate: ${certUrl}\n\n${signoff}`,
+ html: buildBrandedEmailHtml({
+ title: 'Study Complete',
+ eyebrow: 'Certificate of Completion',
+ bodyHtml,
+ ctaLabel,
+ ctaUrl: certUrl,
+ footerHtml,
+ }),
+ })
+ if (error) console.error('[study-certificate] email send error:', error)
+ } catch (err) {
+ console.error('[study-certificate] email exception:', err)
+ }
+}
+
+export async function sendStudyWeeklyDigestEmail(email, displayName, lessons, unsubUrl) {
+ if (!process.env.RESEND_API_KEY) return
+ try {
+ const resend = new Resend(process.env.RESEND_API_KEY)
+ const cfg = state.cachedSiteContent ?? {}
+ const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
+ const subject = cfg.studyWeeklyDigestEmailSubject?.trim() || 'Your weekly study digest'
+ const introText = cfg.studyWeeklyDigestEmailBody?.trim() || 'Here are the lessons released this week in your study track. Open any to continue where you left off.'
+ const signoff = cfg.studyWeeklyDigestEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate'
+ const lessonListHtml = lessons.map(l =>
+ `${escapeHtml(l.sectionTitle)} ` +
+ (l.sectionReference ? ` (${escapeHtml(l.sectionReference)}) ` : '') +
+ (l.studyTitle ? ` — ${escapeHtml(l.studyTitle)} ` : '') +
+ `
`
+ ).join('')
+ const lessonListText = lessons.map(l => `• ${l.sectionTitle}${l.sectionReference ? ` (${l.sectionReference})` : ''}: ${l.sectionUrl}`).join('\n')
+ const bodyHtml = (
+ `Hi ${escapeHtml(namePart)} ,
` +
+ `${escapeHtml(introText)}
` +
+ `${lessonListHtml}
`
+ )
+ const unsubLine = unsubUrl ? `Not interested? Unsubscribe from these reminders.
` : ''
+ const footerHtml = `${escapeHtml(signoff).replace(/\n/g, ' ')}
${unsubLine}`
+ const { error } = await resend.emails.send({
+ from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
+ to: [email],
+ subject,
+ text: `Hi ${namePart},\n\n${introText}\n\n${lessonListText}\n\n${signoff}${unsubUrl ? `\n\nUnsubscribe: ${unsubUrl}` : ''}`,
+ html: buildBrandedEmailHtml({
+ title: 'Weekly Study Digest',
+ eyebrow: 'Study Reminder',
+ bodyHtml,
+ footerHtml,
+ }),
+ })
+ if (error) console.error('[study-digest] send error:', error)
+ } catch (err) {
+ console.error('[study-digest] send exception:', err)
+ }
+}
diff --git a/server/routes/admin-assets.js b/server/routes/admin-assets.js
index 0a48bca..80574e4 100644
--- a/server/routes/admin-assets.js
+++ b/server/routes/admin-assets.js
@@ -9,6 +9,7 @@ import {
readUploadsMetadata,
writeUploadsMetadata,
getUserNotesFilePath,
+ loadUserProgress,
queueStudyUsersWrite,
} from '../data.js'
import {
@@ -16,7 +17,9 @@ import {
getStudyCatalog,
normalizeStudySlug,
isEnrollableStudySlug,
+ makeUnsubUrl,
} from '../study-helpers.js'
+import { sendStudyReengagementEmail } from '../email.js'
export function register(app) {
app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => {
@@ -117,9 +120,19 @@ export function register(app) {
noteCount = Object.values(notes).filter(n => typeof n === 'string' && n.trim()).length
} catch { /* ignore */ }
+ let progressByStudy = {}
+ try {
+ const prog = await loadUserProgress(user.id)
+ for (const [slug, data] of Object.entries(prog.byStudy)) {
+ progressByStudy[slug] = { completedLessons: (data.completedSectionIds ?? []).length }
+ }
+ } catch { /* ignore */ }
+
const enrolledStudies = (user.enrolledStudySlugs ?? []).map(slug => {
const study = catalog.find(s => s.slug === slug)
- return study ? { slug, title: study.title } : { slug, title: slug }
+ return study
+ ? { slug, title: study.title, completedLessons: progressByStudy[slug]?.completedLessons ?? 0 }
+ : { slug, title: slug, completedLessons: 0 }
})
return {
@@ -132,12 +145,50 @@ export function register(app) {
noteCount,
subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
+ reminderFrequency: user.reminderFrequency ?? 'immediate',
+ remindersPausedUntil: user.remindersPausedUntil ?? null,
+ currentStreak: user.currentStreak ?? 0,
+ longestStreak: user.longestStreak ?? 0,
+ reengagementSentAt: user.reengagementSentAt ?? {},
}
}))
res.json({ users })
})
+ app.post('/api/admin/study-users/:id/send-reengagement', requireAdminAuth, async (req, res) => {
+ const user = state.studyUsers.find(u => u.id === req.params.id)
+ if (!user) { res.status(404).json({ message: 'User not found.' }); return }
+
+ const tier = req.body?.tier
+ if (tier !== 't1' && tier !== 't2' && tier !== 't3') {
+ res.status(400).json({ message: 'tier must be t1, t2, or t3.' }); return
+ }
+
+ const studies = Array.isArray(state.cachedSiteContent?.studies) ? state.cachedSiteContent.studies : []
+ const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
+ const enrolledStudies = enrolledStudySlugs
+ .map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug))
+ .filter(Boolean)
+
+ if (enrolledStudies.length === 0) { res.status(400).json({ message: 'User has no enrolled studies.' }); return }
+
+ const firstStudy = enrolledStudies[0]
+ const studyTitle = enrolledStudies.length === 1 ? firstStudy.title : null
+ const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
+ const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
+ const studyUrl = enrolledStudies.length === 1 ? `${base}/study/${firstStudy.slug}` : `${base}/study`
+ const unsubUrl = makeUnsubUrl(user.id)
+
+ try {
+ await sendStudyReengagementEmail(user.username, user.displayName || user.username, studyTitle, studyUrl, tier, unsubUrl)
+ res.json({ ok: true })
+ } catch (err) {
+ console.error(`[admin] manual reengagement send failed for ${user.username}:`, err)
+ res.status(500).json({ message: 'Failed to send email.' })
+ }
+ })
+
app.patch('/api/admin/study-users/:id', requireAdminAuth, (req, res) => {
const user = state.studyUsers.find(u => u.id === req.params.id)
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
diff --git a/server/routes/study-account.js b/server/routes/study-account.js
index d90d553..8dedda8 100644
--- a/server/routes/study-account.js
+++ b/server/routes/study-account.js
@@ -218,6 +218,8 @@ export function register(app) {
displayName: user.displayName ?? '',
subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
+ reminderFrequency: user.reminderFrequency ?? 'immediate',
+ remindersPausedUntil: user.remindersPausedUntil ?? null,
avatarUrl: getStudyAvatarUrl(user),
},
stats: {
@@ -303,6 +305,20 @@ export function register(app) {
const studyRemindersEnabled = req.body?.studyRemindersEnabled === true
user.subscribeNewsletter = subscribeNewsletter
user.studyRemindersEnabled = studyRemindersEnabled
+
+ const reminderFrequency = req.body?.reminderFrequency
+ if (reminderFrequency === 'weekly' || reminderFrequency === 'immediate') {
+ user.reminderFrequency = reminderFrequency
+ }
+
+ const rawPaused = req.body?.remindersPausedUntil
+ if (rawPaused === null || rawPaused === '') {
+ user.remindersPausedUntil = null
+ } else if (typeof rawPaused === 'string') {
+ const parsed = new Date(rawPaused)
+ user.remindersPausedUntil = !isNaN(parsed.getTime()) && parsed.getTime() > Date.now() ? parsed.toISOString() : null
+ }
+
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
@@ -310,7 +326,13 @@ export function register(app) {
syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err))
}
- res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter, studyRemindersEnabled: user.studyRemindersEnabled === true })
+ res.json({
+ ok: true,
+ subscribeNewsletter: user.subscribeNewsletter,
+ studyRemindersEnabled: user.studyRemindersEnabled === true,
+ reminderFrequency: user.reminderFrequency ?? 'immediate',
+ remindersPausedUntil: user.remindersPausedUntil ?? null,
+ })
})
app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => {
diff --git a/server/routes/study-certificate.js b/server/routes/study-certificate.js
index 224512b..ceea801 100644
--- a/server/routes/study-certificate.js
+++ b/server/routes/study-certificate.js
@@ -4,6 +4,8 @@ import { requireAdminAuth } from '../auth.js'
import { requireStudyAuth, normalizeStudySlug, isStudyUserEnrolled, getStudyTitleBySlug } from '../study-helpers.js'
import { state } from '../state.js'
import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js'
+import { sendStudyCertificateEmail, getCanonicalBaseUrl } from '../email.js'
+import { buildAbsoluteUrl } from '../helpers.js'
const publicCertRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
@@ -95,6 +97,9 @@ export function register(app) {
}
state.studyCertificates.push(cert)
queueStudyCertificatesWrite()
+ const certUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/certificate/${cert.token}`)
+ sendStudyCertificateEmail(user.username, user.displayName || user.username, cert.studyTitle, certUrl)
+ .catch(err => console.error('[study-certificate] email exception:', err))
}
res.json({ ok: true, token: cert.token, issuedAt: cert.issuedAt })
diff --git a/server/routes/study-data.js b/server/routes/study-data.js
index 289926c..b76ad75 100644
--- a/server/routes/study-data.js
+++ b/server/routes/study-data.js
@@ -171,6 +171,7 @@ export function register(app) {
const progress = await loadUserProgress(user.id)
const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
+ const bookmarkedSectionIds = progress.byStudy[studySlug]?.bookmarkedSectionIds ?? []
// Record first visit timestamp on the user record (additive, never overwrite)
if (!user.firstVisitAt) {
@@ -178,7 +179,7 @@ export function register(app) {
queueStudyUsersWrite()
}
- res.json({ studySlug, completedSectionIds })
+ res.json({ studySlug, completedSectionIds, bookmarkedSectionIds })
})
app.post('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
@@ -301,6 +302,45 @@ export function register(app) {
res.json({ ok: true, studySlug, sectionId, answers })
})
+ // ── Bookmarks ─────────────────────────────────────────────────────────────
+
+ app.post('/api/study-progress/:studySlug/:sectionId/bookmark', requireStudyAuth, async (req, res) => {
+ const user = req.studyUser
+ const studySlug = normalizeStudySlug(req.params.studySlug)
+ const sectionId = normalizeLessonSectionId(req.params.sectionId)
+ if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return }
+ if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to bookmark lessons.' }); return }
+
+ const progress = await loadUserProgress(user.id)
+ const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] }
+ const bookmarkedSectionIds = Array.isArray(studyProgress.bookmarkedSectionIds) ? studyProgress.bookmarkedSectionIds : []
+ if (!bookmarkedSectionIds.includes(sectionId)) {
+ studyProgress.bookmarkedSectionIds = [...bookmarkedSectionIds, sectionId]
+ }
+ progress.byStudy[studySlug] = studyProgress
+ progress.updatedAt = new Date().toISOString()
+ state.studyProgressCache.set(user.id, progress)
+ queueUserProgressWrite(user.id)
+ res.json({ ok: true, studySlug, bookmarkedSectionIds: studyProgress.bookmarkedSectionIds })
+ })
+
+ app.delete('/api/study-progress/:studySlug/:sectionId/bookmark', requireStudyAuth, async (req, res) => {
+ const user = req.studyUser
+ const studySlug = normalizeStudySlug(req.params.studySlug)
+ const sectionId = normalizeLessonSectionId(req.params.sectionId)
+ if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return }
+ if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Not enrolled.' }); return }
+
+ const progress = await loadUserProgress(user.id)
+ const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] }
+ studyProgress.bookmarkedSectionIds = (studyProgress.bookmarkedSectionIds ?? []).filter(id => id !== sectionId)
+ progress.byStudy[studySlug] = studyProgress
+ progress.updatedAt = new Date().toISOString()
+ state.studyProgressCache.set(user.id, progress)
+ queueUserProgressWrite(user.id)
+ res.json({ ok: true, studySlug, bookmarkedSectionIds: studyProgress.bookmarkedSectionIds })
+ })
+
// ── Community ─────────────────────────────────────────────────────────────
app.get('/api/study-community', requireStudyAuth, async (req, res) => {
diff --git a/server/study-helpers.js b/server/study-helpers.js
index 92dfba1..c223cfc 100644
--- a/server/study-helpers.js
+++ b/server/study-helpers.js
@@ -622,6 +622,9 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) {
for (const user of state.studyUsers) {
if (user.studyRemindersEnabled !== true) continue
+ if (user.reminderFrequency === 'weekly') continue
+ const pausedUntil = user.remindersPausedUntil ? new Date(user.remindersPausedUntil) : null
+ if (pausedUntil && !isNaN(pausedUntil.getTime()) && pausedUntil > now) continue
const email = user.username
const displayName = user.displayName || email
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
@@ -677,7 +680,7 @@ function getReengagementTiers() {
return days.slice(0, 3).map((d, i) => ({ key: `t${i + 1}`, days: d }))
}
-function makeUnsubUrl(userId) {
+export function makeUnsubUrl(userId) {
const secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge'
const token = createHmac('sha256', secret).update(userId).digest('hex')
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
@@ -694,6 +697,8 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
for (const user of state.studyUsers) {
if (user.studyRemindersEnabled !== true) continue
if (!user.lastLoginAt) continue
+ const pausedUntil = user.remindersPausedUntil ? new Date(user.remindersPausedUntil) : null
+ if (pausedUntil && !isNaN(pausedUntil.getTime()) && pausedUntil > now) continue
const lastLogin = new Date(user.lastLoginAt)
if (isNaN(lastLogin.getTime())) continue
const daysSinceLogin = (nowMs - lastLogin.getTime()) / (1000 * 60 * 60 * 24)
@@ -733,3 +738,77 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
}
}
}
+
+// ── Weekly digest scheduler ───────────────────────────────────────────────
+
+export async function scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail) {
+ if (!state.cachedSiteContent) return
+ const now = new Date()
+ const nowMs = now.getTime()
+ const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000
+
+ for (const user of state.studyUsers) {
+ if (user.reminderFrequency !== 'weekly') continue
+ if (user.studyRemindersEnabled !== true) continue
+
+ const pausedUntil = user.remindersPausedUntil ? new Date(user.remindersPausedUntil) : null
+ if (pausedUntil && !isNaN(pausedUntil.getTime()) && pausedUntil > now) continue
+
+ const lastDigestMs = user.weeklyDigestSentAt ? new Date(user.weeklyDigestSentAt).getTime() : 0
+ if (nowMs - lastDigestMs < ONE_WEEK_MS) continue
+
+ const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
+ if (enrolledStudySlugs.length === 0) continue
+
+ const userSent = state.studyReminders.users[user.id] ?? {}
+ const lessons = []
+ const newlySentByStudy = {}
+
+ for (const studySlug of enrolledStudySlugs) {
+ const study = Array.isArray(state.cachedSiteContent.studies)
+ ? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
+ : undefined
+ if (!study) continue
+
+ for (const section of study.sections ?? []) {
+ const releaseTime = getSectionReleaseTime(section)
+ if (!Number.isFinite(releaseTime) || releaseTime > nowMs) continue
+ const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
+ if (sentForStudy.includes(section.id)) continue
+
+ const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
+ const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
+ lessons.push({
+ sectionId: section.id,
+ studySlug,
+ sectionTitle: section.title ?? section.id,
+ sectionReference: section.reference ?? '',
+ studyTitle: study.title ?? studySlug,
+ sectionUrl: `${base}/study/${study.slug}/${section.id}`,
+ })
+ if (!newlySentByStudy[studySlug]) newlySentByStudy[studySlug] = []
+ newlySentByStudy[studySlug].push(section.id)
+ }
+ }
+
+ if (lessons.length === 0) continue
+
+ const unsubUrl = makeUnsubUrl(user.id)
+ try {
+ await sendStudyWeeklyDigestEmail(user.username, user.displayName || user.username, lessons, unsubUrl)
+ } catch (err) {
+ console.error(`[study-digest] failed to send digest to ${user.username}:`, err)
+ continue
+ }
+
+ for (const [studySlug, sectionIds] of Object.entries(newlySentByStudy)) {
+ const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
+ userSent[studySlug] = [...new Set([...sentForStudy, ...sectionIds])]
+ }
+ state.studyReminders.users[user.id] = userSent
+ queueStudyRemindersWrite()
+
+ user.weeklyDigestSentAt = now.toISOString()
+ queueStudyUsersWrite()
+ }
+}
diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx
index 83cf05b..4ba71fe 100644
--- a/src/AdminPage.tsx
+++ b/src/AdminPage.tsx
@@ -78,6 +78,9 @@ interface StudyUserRecord {
noteCount: number
subscribeNewsletter: boolean
studyRemindersEnabled: boolean
+ reminderFrequency: 'immediate' | 'weekly'
+ remindersPausedUntil: string | null
+ reengagementSentAt: Record
}
function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
@@ -89,6 +92,7 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
const [newPassword, setNewPassword] = useState>({})
const [msg, setMsg] = useState>({})
const [confirmDeleteId, setConfirmDeleteId] = useState(null)
+ const [reengagementTier, setReengagementTier] = useState>({})
function flashMsg(id: string, text: string) {
setMsg(prev => ({ ...prev, [id]: text }))
@@ -154,6 +158,20 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
}
+ async function handleSendReengagement(user: StudyUserRecord) {
+ const tier = reengagementTier[user.id] ?? 't1'
+ try {
+ const res = await fetch(`/api/admin/study-users/${user.id}/send-reengagement`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tier }),
+ })
+ const data = await res.json()
+ if (!res.ok) throw new Error(data.message ?? 'Error')
+ flashMsg(user.id, `Tier ${tier.slice(1)} re-engagement email sent.`)
+ } catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
+ }
+
async function handleDelete(id: string) {
try {
await fetch(`/api/admin/study-users/${id}`, { method: 'DELETE' })
@@ -222,7 +240,13 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
Last studied: {user.lastStudiedDate ?? '—'}
Streak: {user.currentStreak ?? 0} 🔥 (best: {user.longestStreak ?? 0})
Newsletter: {user.subscribeNewsletter ? 'Yes' : 'No'}
- Reminders: {user.studyRemindersEnabled ? 'On' : 'Off'}
+ Reminders: {user.studyRemindersEnabled ? 'On' : 'Off'} · {user.reminderFrequency === 'weekly' ? 'Weekly digest' : 'Per-lesson'}
+ {user.remindersPausedUntil && new Date(user.remindersPausedUntil) > new Date() && (
+ Paused until {new Date(user.remindersPausedUntil).toLocaleDateString()}
+ )}
+ {user.reengagementSentAt && Object.keys(user.reengagementSentAt).length > 0 && (
+ Re-engagement sent: {Object.keys(user.reengagementSentAt).sort().join(', ')}
+ )}
{/* Display Name */}
@@ -292,6 +316,25 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
)}
+ {/* Re-engagement */}
+ {user.enrolledStudies.length > 0 && (
+
+
Send Re-engagement Email
+
+ setReengagementTier(prev => ({ ...prev, [user.id]: e.target.value }))}
+ >
+ Tier 1 (gentle nudge)
+ Tier 2 (momentum)
+ Tier 3 (still here)
+
+ handleSendReengagement(user)}>Send
+
+
Sends the selected tier email immediately, regardless of inactivity threshold.
+
+ )}
+
{msg[user.id] && {msg[user.id]}
}
{/* Delete */}
@@ -419,6 +462,18 @@ function EmailTemplatesPanel({
fieldKeys={['studyReengagementDays','studyReengagementT1Subject','studyReengagementT1Body','studyReengagementT1Cta','studyReengagementT2Subject','studyReengagementT2Body','studyReengagementT2Cta','studyReengagementT3Subject','studyReengagementT3Body','studyReengagementT3Cta']}
openKey="study-reengagement" />
+
+
+
+
type StudyProgress = {
completedSectionIds: string[]
+ bookmarkedSectionIds: string[]
}
type StudyCommunityReply = {
@@ -1256,7 +1259,7 @@ export function ColossiansStudyIndexPage({ content }: Props) {
const [auth, setAuth] = useState({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [] })
const [enrolling, setEnrolling] = useState(false)
const [enrollMessage, setEnrollMessage] = useState('')
- const [studyProgress, setStudyProgress] = useState({ completedSectionIds: [] })
+ const [studyProgress, setStudyProgress] = useState({ completedSectionIds: [], bookmarkedSectionIds: [] })
const [progressLoading, setProgressLoading] = useState(false)
useEffect(() => {
@@ -1305,14 +1308,14 @@ export function ColossiansStudyIndexPage({ content }: Props) {
let cancelled = false
setProgressLoading(true)
- readJson<{ studySlug: string; completedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(study.slug)}`)
+ readJson<{ studySlug: string; completedSectionIds: string[]; bookmarkedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(study.slug)}`)
.then(data => {
if (cancelled) return
- setStudyProgress({ completedSectionIds: Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [] })
+ setStudyProgress({ completedSectionIds: Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [], bookmarkedSectionIds: Array.isArray(data.bookmarkedSectionIds) ? data.bookmarkedSectionIds : [] })
})
.catch(() => {
if (cancelled) return
- setStudyProgress({ completedSectionIds: [] })
+ setStudyProgress({ completedSectionIds: [], bookmarkedSectionIds: [] })
})
.finally(() => {
if (cancelled) return
@@ -1499,6 +1502,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
const noteMapDirtyRef = useRef(false)
const [contextNoteMenu, setContextNoteMenu] = useState<{ x: number; y: number; text: string; anchor: NoteAnchorId } | null>(null)
const [completedSectionIds, setCompletedSectionIds] = useState([])
+ const [bookmarkedSectionIds, setBookmarkedSectionIds] = useState([])
+ const [bookmarkSaving, setBookmarkSaving] = useState(false)
const [progressSaving, setProgressSaving] = useState(false)
const [progressMessage, setProgressMessage] = useState('')
const [checkpointAnswers, setCheckpointAnswers] = useState>({})
@@ -1568,14 +1573,16 @@ export function ColossiansStudySectionPage({ content }: Props) {
let cancelled = false
setProgressMessage('')
- readJson<{ studySlug: string; completedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(currentStudySlug)}`)
+ readJson<{ studySlug: string; completedSectionIds: string[]; bookmarkedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(currentStudySlug)}`)
.then(data => {
if (cancelled) return
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
+ setBookmarkedSectionIds(Array.isArray(data.bookmarkedSectionIds) ? data.bookmarkedSectionIds : [])
})
.catch(() => {
if (cancelled) return
setCompletedSectionIds([])
+ setBookmarkedSectionIds([])
})
.finally(() => {
if (cancelled) return
@@ -2017,30 +2024,56 @@ export function ColossiansStudySectionPage({ content }: Props) {
Lesson Progress
No checkpoint questions for this lesson.
- {
- if (!study || !section?.id) return
- setProgressSaving(true)
- const wasComplete = lessonCompleted
- try {
- const method = wasComplete ? 'DELETE' : 'POST'
- const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(
- `/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`,
- { method }
- )
- const newIds = Array.isArray(data.completedSectionIds) ? data.completedSectionIds : []
- setCompletedSectionIds(newIds)
- if (!wasComplete) checkAndShowCelebration(newIds, false)
- } catch { /* ignore */ }
- finally { setProgressSaving(false) }
- }}
- >
- {progressSaving ? '…' : lessonCompleted ? '✓ Completed' : 'Mark as Completed'}
-
+
+ {
+ if (!study || !section?.id) return
+ setProgressSaving(true)
+ const wasComplete = lessonCompleted
+ try {
+ const method = wasComplete ? 'DELETE' : 'POST'
+ const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(
+ `/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`,
+ { method }
+ )
+ const newIds = Array.isArray(data.completedSectionIds) ? data.completedSectionIds : []
+ setCompletedSectionIds(newIds)
+ if (!wasComplete) checkAndShowCelebration(newIds, false)
+ } catch { /* ignore */ }
+ finally { setProgressSaving(false) }
+ }}
+ >
+ {progressSaving ? '…' : lessonCompleted ? '✓ Completed' : 'Mark as Completed'}
+
+ {isEnrolled && section?.id && (
+ {
+ if (!study || !section?.id) return
+ setBookmarkSaving(true)
+ const isBookmarked = bookmarkedSectionIds.includes(section.id)
+ try {
+ const data = await readJson<{ ok: boolean; bookmarkedSectionIds: string[] }>(
+ `/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}/bookmark`,
+ { method: isBookmarked ? 'DELETE' : 'POST' }
+ )
+ setBookmarkedSectionIds(Array.isArray(data.bookmarkedSectionIds) ? data.bookmarkedSectionIds : [])
+ } catch { /* ignore */ }
+ finally { setBookmarkSaving(false) }
+ }}
+ >
+ {bookmarkSaving ? '…' : bookmarkedSectionIds.includes(section.id) ? '🔖 Bookmarked' : '🔖 Bookmark'}
+
+ )}
+
)}
@@ -2439,6 +2472,8 @@ export function StudyAccountPage() {
const [subscribeNewsletter, setSubscribeNewsletter] = useState(true)
const [studyRemindersEnabled, setStudyRemindersEnabled] = useState(true)
+ const [reminderFrequency, setReminderFrequency] = useState<'immediate' | 'weekly'>('immediate')
+ const [remindersPausedUntil, setRemindersPausedUntil] = useState(null)
const [prefMessage, setPrefMessage] = useState('')
const [prefBusy, setPrefBusy] = useState(false)
const [accountModal, setAccountModal] = useState<'none' | 'enrollments' | 'changeEmail' | 'changePassword' | 'profile' | 'preferences' | 'export' | 'choose2fa' | 'setup2fa' | 'setup2fa-email' | 'disable2fa' | 'recoveryCodes'>('none')
@@ -2509,6 +2544,8 @@ export function StudyAccountPage() {
setAvatarUrlInput(data.profile.avatarUrl ?? '')
setSubscribeNewsletter(data.profile.subscribeNewsletter !== false)
setStudyRemindersEnabled(data.profile.studyRemindersEnabled === true)
+ setReminderFrequency(data.profile.reminderFrequency === 'weekly' ? 'weekly' : 'immediate')
+ setRemindersPausedUntil(data.profile.remindersPausedUntil ?? null)
})
.catch(() => {})
.finally(() => setLoadingOverview(false))
@@ -2541,6 +2578,8 @@ export function StudyAccountPage() {
setAvatarUrlInput(data.profile.avatarUrl ?? '')
setSubscribeNewsletter(data.profile.subscribeNewsletter !== false)
setStudyRemindersEnabled(data.profile.studyRemindersEnabled === true)
+ setReminderFrequency(data.profile.reminderFrequency === 'weekly' ? 'weekly' : 'immediate')
+ setRemindersPausedUntil(data.profile.remindersPausedUntil ?? null)
}
async function handleAvatarUpload(file: File) {
@@ -2600,12 +2639,14 @@ export function StudyAccountPage() {
setPrefBusy(true)
setPrefMessage('')
try {
- const data = await readJson<{ ok: boolean; subscribeNewsletter: boolean; studyRemindersEnabled: boolean }>('/api/study-account/preferences', {
+ const data = await readJson<{ ok: boolean; subscribeNewsletter: boolean; studyRemindersEnabled: boolean; reminderFrequency: string; remindersPausedUntil: string | null }>('/api/study-account/preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ subscribeNewsletter, studyRemindersEnabled }),
+ body: JSON.stringify({ subscribeNewsletter, studyRemindersEnabled, reminderFrequency, remindersPausedUntil }),
})
setAuth(prev => ({ ...prev, subscribeNewsletter: data.subscribeNewsletter, studyRemindersEnabled: data.studyRemindersEnabled }))
+ setReminderFrequency(data.reminderFrequency === 'weekly' ? 'weekly' : 'immediate')
+ setRemindersPausedUntil(data.remindersPausedUntil ?? null)
setPrefMessage('Preferences updated.')
await refreshOverview()
} catch (err) {
@@ -3008,6 +3049,99 @@ export function StudyAccountPage() {
setStudyRemindersEnabled(e.target.checked)} />
Send email reminders when a new lesson is released
+ {studyRemindersEnabled && (
+
+
+
Reminder frequency
+
+ setReminderFrequency('immediate')}
+ style={{
+ padding: '0.35rem 0.75rem',
+ borderRadius: '0.375rem',
+ border: reminderFrequency === 'immediate' ? '1px solid #d4af7a' : '1px solid #444',
+ background: reminderFrequency === 'immediate' ? '#2a2218' : 'transparent',
+ color: reminderFrequency === 'immediate' ? '#d4af7a' : '#b9b09b',
+ fontSize: '0.875rem',
+ cursor: 'pointer',
+ }}
+ >
+ Per-lesson
+
+ setReminderFrequency('weekly')}
+ style={{
+ padding: '0.35rem 0.75rem',
+ borderRadius: '0.375rem',
+ border: reminderFrequency === 'weekly' ? '1px solid #d4af7a' : '1px solid #444',
+ background: reminderFrequency === 'weekly' ? '#2a2218' : 'transparent',
+ color: reminderFrequency === 'weekly' ? '#d4af7a' : '#b9b09b',
+ fontSize: '0.875rem',
+ cursor: 'pointer',
+ }}
+ >
+ Weekly digest
+
+
+
+ {reminderFrequency === 'weekly'
+ ? 'Receive one weekly email summarizing all new lessons.'
+ : 'Receive an email each time a new lesson is released.'}
+
+
+
+
+ Pause reminders
+ {remindersPausedUntil && new Date(remindersPausedUntil) > new Date()
+ ? ` · paused until ${new Date(remindersPausedUntil).toLocaleDateString()}`
+ : ''}
+
+
+ {[7, 14, 30].map(days => (
+ {
+ const d = new Date()
+ d.setDate(d.getDate() + days)
+ setRemindersPausedUntil(d.toISOString())
+ }}
+ style={{
+ padding: '0.35rem 0.75rem',
+ borderRadius: '0.375rem',
+ border: '1px solid #444',
+ background: 'transparent',
+ color: '#b9b09b',
+ fontSize: '0.875rem',
+ cursor: 'pointer',
+ }}
+ >
+ {days} days
+
+ ))}
+ {remindersPausedUntil && new Date(remindersPausedUntil) > new Date() && (
+ setRemindersPausedUntil(null)}
+ style={{
+ padding: '0.35rem 0.75rem',
+ borderRadius: '0.375rem',
+ border: '1px solid #555',
+ background: 'transparent',
+ color: '#7a7060',
+ fontSize: '0.875rem',
+ cursor: 'pointer',
+ }}
+ >
+ Clear pause
+
+ )}
+
+
+
+ )}
setSubscribeNewsletter(e.target.checked)} />
Subscribe to newsletter updates
diff --git a/src/content.ts b/src/content.ts
index fc48cd2..f594218 100644
--- a/src/content.ts
+++ b/src/content.ts
@@ -259,6 +259,15 @@ export interface SiteContent {
studyReengagementT3Subject: string
studyReengagementT3Body: string
studyReengagementT3Cta: string
+ // ── Study Completion Certificate Email ──
+ studyCertificateEmailSubject: string
+ studyCertificateEmailBody: string
+ studyCertificateEmailCtaLabel: string
+ studyCertificateEmailSignoff: string
+ // ── Study Weekly Lesson Digest Email ──
+ studyWeeklyDigestEmailSubject: string
+ studyWeeklyDigestEmailBody: string
+ studyWeeklyDigestEmailSignoff: string
// ── Email Change Confirmation ──
emailChangeSubject: string
emailChangeBody: string
@@ -472,6 +481,13 @@ export const DEFAULTS: SiteContent = {
studyReengagementT3Subject: 'Your progress is still here',
studyReengagementT3Body: "Your study progress is still intact and waiting. There's no deadline — come back whenever you're ready.",
studyReengagementT3Cta: 'Open Your Study',
+ studyCertificateEmailSubject: 'Your certificate is ready',
+ studyCertificateEmailBody: 'Congratulations — you have completed every lesson in this study. Your certificate is ready to view and share.',
+ studyCertificateEmailCtaLabel: 'View Certificate',
+ studyCertificateEmailSignoff: 'Grace and peace,\nVerse by Verse with Nate',
+ studyWeeklyDigestEmailSubject: 'Your weekly study digest',
+ studyWeeklyDigestEmailBody: 'Here are the lessons released this week in your study track. Open any to continue where you left off.',
+ studyWeeklyDigestEmailSignoff: 'Grace and peace,\nVerse by Verse with Nate',
emailChangeSubject: 'Confirm your new email address',
emailChangeBody: 'Click the link below to confirm your new account email. If you did not request this change, ignore this message.',
emailChangeCtaLabel: 'Confirm Email Change',
From 874dd0c89b3b51e1fccc137638e49c2ec5c7763c Mon Sep 17 00:00:00 2001
From: nmemmert
Date: Thu, 3 Sep 2026 16:01:15 -0400
Subject: [PATCH 3/6] Redesign study lesson UI: progress bar, sidebar nav,
inline answers; v1.2.5
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Progress bar: thin gold bar under lesson label showing completed/total lessons
- Sidebar lesson navigator: all lessons listed with ✓/current/○ states, replaces static Study Track + Lesson Navigation blocks
- Notes panel moves to sidebar: clicking a block's Note button opens NoteCard in sidebar instead of inline, preserving reading position
- Inline reflection answers: textareas under each question auto-save to quiz API; replaces separate quiz page link
- Note buttons: "Note" / "✎ Note" text label instead of bare pencil icon for better discoverability
- Greek Word Study block hidden when lesson has no greek notes
Co-Authored-By: Claude Sonnet 4.6
---
package.json | 2 +-
src/App.css | 191 ++++++++++++++++++++++++++++++-
src/colossiansStudy.tsx | 243 +++++++++++++++++++++++-----------------
3 files changed, 328 insertions(+), 108 deletions(-)
diff --git a/package.json b/package.json
index 9dad657..22148ec 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
- "version": "1.2.4",
+ "version": "1.2.5",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/App.css b/src/App.css
index 8a862ef..82e6061 100644
--- a/src/App.css
+++ b/src/App.css
@@ -2668,17 +2668,20 @@
.note-anchor-btn {
position: absolute;
- top: 1rem;
- right: 1rem;
+ top: 0.85rem;
+ right: 0.85rem;
background: transparent;
border: 1px solid #3a3828;
border-radius: 6px;
color: #5a5440;
- padding: 0.2rem 0.55rem;
- font-size: 0.9rem;
+ padding: 0.22rem 0.65rem;
+ font-size: 0.78rem;
+ font-family: var(--brand-font-body);
+ font-weight: 500;
+ letter-spacing: 0.02em;
cursor: pointer;
transition: color 150ms, border-color 150ms, background 150ms;
- line-height: 1;
+ line-height: 1.4;
z-index: 1;
}
@@ -2699,6 +2702,184 @@
border-color: rgba(224, 192, 112, 0.5);
}
+/* ── Progress bar ────────────────────────────────────────────────── */
+.study-lesson-progress-bar {
+ height: 3px;
+ background: rgba(201, 168, 76, 0.12);
+ border-radius: 2px;
+ margin: 0.35rem 0 0.1rem;
+ overflow: hidden;
+}
+
+.study-lesson-progress-fill {
+ height: 100%;
+ background: #c9a84c;
+ border-radius: 2px;
+ transition: width 0.4s ease;
+ min-width: 3px;
+}
+
+/* ── Sidebar lesson navigator ────────────────────────────────────── */
+.study-lesson-nav-progress {
+ font-size: 0.72rem;
+ font-weight: 400;
+ color: #5a5440;
+ margin-left: 0.4rem;
+ letter-spacing: 0.02em;
+}
+
+.study-lesson-nav {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ margin-top: 0.25rem;
+ max-height: 320px;
+ overflow-y: auto;
+}
+
+.study-lesson-nav-item {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ padding: 0.42rem 0.55rem;
+ border-radius: 7px;
+ border: 1px solid transparent;
+ text-decoration: none;
+ transition: background 120ms, border-color 120ms;
+ cursor: pointer;
+}
+
+.study-lesson-nav-item:hover {
+ background: rgba(201, 168, 76, 0.06);
+}
+
+.study-lesson-nav-item--current {
+ background: rgba(201, 168, 76, 0.1);
+ border-color: rgba(201, 168, 76, 0.2);
+}
+
+.study-lesson-nav-item--locked {
+ opacity: 0.45;
+ cursor: default;
+ pointer-events: none;
+}
+
+.study-lesson-nav-icon {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.72rem;
+ font-weight: 700;
+ flex-shrink: 0;
+ background: rgba(255, 255, 255, 0.05);
+ color: #5a5440;
+}
+
+.study-lesson-nav-item--done .study-lesson-nav-icon {
+ background: rgba(201, 168, 76, 0.18);
+ color: #c9a84c;
+}
+
+.study-lesson-nav-item--current .study-lesson-nav-icon {
+ background: #c9a84c;
+ color: #0e0d0b;
+}
+
+.study-lesson-nav-label {
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: #7a7060;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+
+.study-lesson-nav-item--current .study-lesson-nav-label {
+ color: #e2d4a8;
+}
+
+.study-lesson-nav-item--done .study-lesson-nav-label {
+ color: #5a5040;
+}
+
+.study-lesson-nav-actions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 0.25rem;
+}
+
+/* ── Notes count badge ────────────────────────────────────────────── */
+.study-notes-count-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ border-radius: 9px;
+ background: rgba(201, 168, 76, 0.18);
+ color: #c9a84c;
+ font-size: 0.7rem;
+ font-weight: 600;
+ padding: 0 5px;
+ margin-left: 0.4rem;
+ font-family: var(--brand-font-body);
+ vertical-align: middle;
+}
+
+/* ── Inline reflection question answers ─────────────────────────── */
+.study-detail-list--questions {
+ list-style: none;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+ margin-top: 0.25rem;
+}
+
+.study-question-item {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.study-question-text {
+ color: var(--brand-muted);
+ font-size: 0.93rem;
+ line-height: 1.6;
+ margin: 0;
+}
+
+.study-question-answer {
+ width: 100%;
+ box-sizing: border-box;
+ background: rgba(201, 168, 76, 0.04);
+ border: 1px solid rgba(201, 168, 76, 0.15);
+ border-radius: 8px;
+ padding: 0.65rem 0.85rem;
+ font-size: 0.88rem;
+ font-family: var(--brand-font-body);
+ color: #f0ead8;
+ resize: vertical;
+ min-height: 72px;
+ line-height: 1.6;
+ transition: border-color 150ms;
+}
+
+.study-question-answer:focus {
+ outline: none;
+ border-color: rgba(201, 168, 76, 0.4);
+}
+
+.study-question-answer::placeholder {
+ color: #3a3020;
+}
+
/* ── Sidebar anchor list ─────────────────────────────────────────── */
.note-anchor-list {
display: flex;
diff --git a/src/colossiansStudy.tsx b/src/colossiansStudy.tsx
index 982f7e2..169ee6b 100644
--- a/src/colossiansStudy.tsx
+++ b/src/colossiansStudy.tsx
@@ -1507,6 +1507,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
const [progressSaving, setProgressSaving] = useState(false)
const [progressMessage, setProgressMessage] = useState('')
const [checkpointAnswers, setCheckpointAnswers] = useState>({})
+ const [quizAnswers, setQuizAnswers] = useState([])
+ const quizAnswersDirtyRef = useRef(false)
const [showCelebration, setShowCelebration] = useState(false)
const [bannerDismissed, setBannerDismissed] = useState(() => localStorage.getItem('study-banner-dismissed-v1') === '1')
const [autoSaveMsg, setAutoSaveMsg] = useState('')
@@ -1692,6 +1694,44 @@ export function ColossiansStudySectionPage({ content }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [noteMap, noteId, auth.authenticated, noteLoading])
+ useEffect(() => {
+ if (!auth.authenticated || !isEnrolled || !study || !section?.id) {
+ setQuizAnswers([])
+ quizAnswersDirtyRef.current = false
+ return
+ }
+ let cancelled = false
+ readJson<{ answers: string[] }>(`/api/study-quiz/${encodeURIComponent(currentStudySlug)}/${encodeURIComponent(section.id)}`)
+ .then(data => {
+ if (cancelled) return
+ setQuizAnswers(Array.isArray(data.answers) ? data.answers : [])
+ quizAnswersDirtyRef.current = false
+ })
+ .catch(() => { if (!cancelled) setQuizAnswers([]) })
+ return () => { cancelled = true }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [auth.authenticated, isEnrolled, currentStudySlug, section?.id])
+
+ useEffect(() => {
+ if (!auth.authenticated || !isEnrolled || !study || !section?.id) return
+ if (!quizAnswersDirtyRef.current) return
+ const timer = setTimeout(async () => {
+ if (!quizAnswersDirtyRef.current) return
+ quizAnswersDirtyRef.current = false
+ try {
+ await readJson<{ ok: boolean; answers: string[] }>(`/api/study-quiz/${encodeURIComponent(currentStudySlug)}/${encodeURIComponent(section.id)}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ answers: quizAnswers }),
+ })
+ } catch {
+ quizAnswersDirtyRef.current = true
+ }
+ }, 1500)
+ return () => clearTimeout(timer)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [quizAnswers, auth.authenticated, isEnrolled, currentStudySlug, section?.id])
+
useEffect(() => {
if (!showCelebration) return
function onKeyDown(e: KeyboardEvent) {
@@ -1896,6 +1936,11 @@ export function ColossiansStudySectionPage({ content }: Props) {
Back to {study.title}
Lesson {lessonNumber} of {sections.length}
+ {sections.length > 0 && (
+
+ )}
{section.title}
{section.reference}
{section.summary}
@@ -1927,9 +1972,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
type="button"
className={`note-anchor-btn${noteMap['scripture']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'scripture' ? ' note-anchor-btn--open' : ''}`}
onClick={() => setOpenAnchor(curr => curr === 'scripture' ? null : 'scripture')}
- title={noteMap['scripture']?.trim() ? 'Edit scripture note' : 'Add note to Scripture'}
aria-pressed={openAnchor === 'scripture'}
- >✎
+ >{noteMap['scripture']?.trim() ? '✎ Note' : '+ Note'}
)}
Scripture Text
{bsbPassageLoading
@@ -1943,15 +1987,6 @@ export function ColossiansStudySectionPage({ content }: Props) {
: Passage text not available.
}
{bsbPassage && Berean Standard Bible (BSB)
}
- {isEnrolled && openAnchor === 'scripture' && (
- updateNote('scripture', html)}
- onClose={() => setOpenAnchor(null)}
- />
- )}
@@ -1960,9 +1995,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
type="button"
className={`note-anchor-btn${noteMap['commentary']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'commentary' ? ' note-anchor-btn--open' : ''}`}
onClick={() => setOpenAnchor(curr => curr === 'commentary' ? null : 'commentary')}
- title={noteMap['commentary']?.trim() ? 'Edit commentary note' : 'Add note to Commentary'}
aria-pressed={openAnchor === 'commentary'}
- >✎
+ >{noteMap['commentary']?.trim() ? '✎ Note' : '+ Note'}
)}
Instructor Commentary
{section.commentary.split(/\n{2,}/).map((para, i) => (
@@ -1972,15 +2006,6 @@ export function ColossiansStudySectionPage({ content }: Props) {
))}
))}
- {isEnrolled && openAnchor === 'commentary' && (
- updateNote('commentary', html)}
- onClose={() => setOpenAnchor(null)}
- />
- )}
@@ -1989,33 +2014,35 @@ export function ColossiansStudySectionPage({ content }: Props) {
type="button"
className={`note-anchor-btn${noteMap['questions']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'questions' ? ' note-anchor-btn--open' : ''}`}
onClick={() => setOpenAnchor(curr => curr === 'questions' ? null : 'questions')}
- title={noteMap['questions']?.trim() ? 'Edit reflection note' : 'Add note to Reflection Questions'}
aria-pressed={openAnchor === 'questions'}
- >✎
+ >{noteMap['questions']?.trim() ? '✎ Note' : '+ Note'}
)}
Reflection Questions
-
- Work through these on your own, then share your thoughts in the Discussion below.
-
-
+
{section.studyQuestions.map((question, index) => (
- {question}
+
+ {question}
+ {isEnrolled && (
+ {
+ const next = [...quizAnswers]
+ next[index] = e.target.value
+ setQuizAnswers(next)
+ quizAnswersDirtyRef.current = true
+ }}
+ placeholder="Write your answer…"
+ rows={2}
+ />
+ )}
+
))}
- Write My Answers
Go to Community
Ask Nate
- {isEnrolled && openAnchor === 'questions' && (
- updateNote('questions', html)}
- onClose={() => setOpenAnchor(null)}
- />
- )}
{isEnrolled && !(section.checkpointQuestions && section.checkpointQuestions.length > 0) && (
@@ -2133,28 +2160,40 @@ export function ColossiansStudySectionPage({ content }: Props) {
+ {/* Lesson navigator */}
- Study Track
- {study.title}
- {study.description}
+ Lessons {completedSectionIds.length}/{sections.length}
+
+ {sections.map(s => {
+ const sNum = getLessonNumber(sections, s.id)
+ const isDone = completedSectionIds.includes(s.id)
+ const isCurr = s.id === section.id
+ const released = isSectionReleased(s)
+ return (
+ { if (!released) e.preventDefault() }}
+ >
+
+ {isDone ? '✓' : isCurr ? String(sNum) : released ? String(sNum) : '○'}
+
+ {s.title}
+
+ )
+ })}
+
+ {/* Notes panel */}
- Greek Word Study
- {section.greekNotes.length > 0 ? (
-
- {section.greekNotes.map((note, index) => (
- {note}
- ))}
-
- ) : (
- Greek notes will be added for this lesson.
- )}
-
-
-
- Student Notes
- {!auth.checked && Checking sign-in status...
}
+
+ Your Notes
+ {savedNoteCount > 0 && {savedNoteCount} }
+
+ {!auth.checked && Checking sign-in…
}
{auth.checked && !auth.authenticated && (
Sign in to save notes for this lesson.
@@ -2166,60 +2205,60 @@ export function ColossiansStudySectionPage({ content }: Props) {
submitAuth('login')}>Sign In
submitAuth('signup')}>Create Account
+ {authMessage && {authMessage}
}
)}
{auth.checked && auth.authenticated && (
-
-
Signed in as {auth.username}
-
My Account
+ <>
{noteLoading ? (
-
Loading notes…
+
Loading notes…
+ ) : openAnchor ? (
+
updateNote(openAnchor as NoteAnchorId, html)}
+ onClose={() => setOpenAnchor(null)}
+ />
) : (
- <>
- {savedNoteCount > 0 && (
-
- {savedNoteCount} note{savedNoteCount !== 1 ? 's' : ''} saved ✓
-
- )}
-
- {NOTE_ANCHORS.map(({ id, label }) => {
- const hasNote = !!(noteMap[id]?.trim())
- return (
- setOpenAnchor(curr => curr === id ? null : id as NoteAnchorId)}
- >
- {label}
- {hasNote && ● }
-
- )
- })}
-
- Click a section above to add notes. Right-click lesson text to append a selection.
- >
+
+ {savedNoteCount > 0
+ ? `${savedNoteCount} note${savedNoteCount !== 1 ? 's' : ''} saved. Click a section's Note button to edit.`
+ : 'Click the Note button on Scripture, Commentary, or Reflection Questions to write notes here.'}
+
)}
-
+ {autoSaveMsg && {autoSaveMsg}
}
+ {noteMessage && {noteMessage}
}
+ My Account →
+ >
)}
- {(authMessage || noteMessage || progressMessage) && {authMessage || noteMessage || progressMessage}
}
-
- Lesson Navigation
-
- {previousSection ? (
- Previous Lesson
- ) : }
- {nextSection && !isSectionReleased(nextSection) ? (
- Available {getReleaseDateDisplay(nextSection)}
- ) : nextSection && !lessonCompleted ? (
- 🔒 Complete checkpoint first
- ) : nextSection ? (
- Next Lesson
- ) : }
-
-
+ {/* Greek Word Study */}
+ {section.greekNotes.length > 0 && (
+
+ Greek Word Study
+
+ {section.greekNotes.map((note, index) => (
+ {note}
+ ))}
+
+
+ )}
+
+ {/* Prev / Next navigation */}
+
+ {previousSection ? (
+ ← Previous
+ ) : }
+ {nextSection && !isSectionReleased(nextSection) ? (
+ Next available {getReleaseDateDisplay(nextSection)}
+ ) : nextSection && !lessonCompleted ? (
+ Complete lesson to continue
+ ) : nextSection ? (
+ Next →
+ ) : null}
+
{contextNoteMenu && (
From 49e3a80c2c8be30b7a83cd818a428aa4a0dfe6f5 Mon Sep 17 00:00:00 2001
From: nmemmert
Date: Thu, 3 Sep 2026 16:34:28 -0400
Subject: [PATCH 4/6] Redesign community section with social media feed UX;
v1.2.6
- Twitter/X-style post cards: avatar thread line, author + relative timestamp
- Heart like button with optimistic toggle and like count per post
- Comment button with reply count; inline threaded replies always visible
- Compose card with user avatar, borderless textarea, pill Post button
- Horizontal scrollable lesson filter chips (All + per-lesson)
- New POST /api/study-community/posts/:postId/like endpoint (toggle)
- GET community posts now returns likedByMe + likeCount per post
- sanitizeStudyCommunityPosts preserves likedBy array on disk
- Relative timestamps (just now, Xm ago, Xh ago, Xd ago, then date)
- UserAvatar helper renders img or initials fallback consistently
Co-Authored-By: Claude Sonnet 4.6
---
package.json | 2 +-
server/data.js | 2 +
server/routes/study-data.js | 22 +++
src/App.css | 70 +++++++++
src/colossiansStudy.tsx | 300 ++++++++++++++++++++----------------
5 files changed, 261 insertions(+), 135 deletions(-)
diff --git a/package.json b/package.json
index 22148ec..9e6d173 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
- "version": "1.2.5",
+ "version": "1.2.6",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/server/data.js b/server/data.js
index 542f391..e68eef8 100644
--- a/server/data.js
+++ b/server/data.js
@@ -1523,6 +1523,7 @@ export function sanitizeStudyCommunityPosts(value) {
? item.replies.map(sanitizeStudyCommunityReply).filter(Boolean).slice(0, 50)
: []
if (!studySlug || !message) return null
+ const likedBy = Array.isArray(item.likedBy) ? item.likedBy.filter(id => typeof id === 'string' && id.trim()).slice(0, 1000) : []
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
studySlug,
@@ -1532,6 +1533,7 @@ export function sanitizeStudyCommunityPosts(value) {
message,
createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
replies,
+ likedBy,
}
})
.filter(Boolean)
diff --git a/server/routes/study-data.js b/server/routes/study-data.js
index b76ad75..47e290e 100644
--- a/server/routes/study-data.js
+++ b/server/routes/study-data.js
@@ -363,9 +363,12 @@ export function register(app) {
.slice(0, 50)
.map(post => {
const author = findStudyUserById(post.authorUserId)
+ const likedBy = Array.isArray(post.likedBy) ? post.likedBy : []
return {
...post,
authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''),
+ likedByMe: likedBy.includes(user.id),
+ likeCount: likedBy.length,
replies: Array.isArray(post.replies)
? post.replies.map(reply => {
const replyAuthor = findStudyUserById(reply.authorUserId)
@@ -452,4 +455,23 @@ export function register(app) {
res.json({ ok: true, reply })
})
+
+ app.post('/api/study-community/posts/:postId/like', requireStudyAuth, (req, res) => {
+ const user = req.studyUser
+ const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : ''
+ const post = state.studyCommunityPosts.find(item => item.id === postId)
+ if (!post) { res.status(404).json({ message: 'Post not found.' }); return }
+ if (!isStudyUserEnrolled(user, post.studySlug)) {
+ res.status(403).json({ message: 'Please enroll in this study to like posts.' }); return
+ }
+ if (!Array.isArray(post.likedBy)) post.likedBy = []
+ const idx = post.likedBy.indexOf(user.id)
+ if (idx === -1) {
+ post.likedBy.push(user.id)
+ } else {
+ post.likedBy.splice(idx, 1)
+ }
+ queueStudyCommunityWrite()
+ res.json({ ok: true, likeCount: post.likedBy.length, likedByMe: post.likedBy.includes(user.id) })
+ })
}
diff --git a/src/App.css b/src/App.css
index 82e6061..4ba82cc 100644
--- a/src/App.css
+++ b/src/App.css
@@ -12550,3 +12550,73 @@
.admin-save-ok { color: #6ebd7c; font-size: 0.85rem; margin: 0; }
.admin-save-error { color: #e05c5c; font-size: 0.85rem; margin: 0; }
.admin-field-hint { font-size: 0.78rem; color: var(--admin-muted, #a0957e); margin: 0; }
+
+/* ── Community feed ──────────────────────────────────────────────────────── */
+.community-feed { display: flex; flex-direction: column; gap: 0; }
+
+.community-filter-bar { display: flex; gap: 0.4rem; flex-wrap: nowrap; overflow-x: auto; padding-bottom: 0.25rem; margin-bottom: 1.25rem; scrollbar-width: none; }
+.community-filter-bar::-webkit-scrollbar { display: none; }
+.community-filter-chip { flex-shrink: 0; padding: 0.3rem 0.85rem; border-radius: 999px; border: 1px solid var(--brand-border, #2a2518); background: transparent; color: var(--brand-muted, #b0a48c); font-size: 0.82rem; font-weight: 500; cursor: pointer; white-space: nowrap; transition: background 0.15s, color 0.15s, border-color 0.15s; }
+.community-filter-chip:hover { background: rgba(201,168,76,0.08); color: var(--brand-gold, #c9a84c); border-color: var(--brand-gold, #c9a84c); }
+.community-filter-chip--active { background: rgba(201,168,76,0.12); color: var(--brand-gold, #c9a84c); border-color: var(--brand-gold, #c9a84c); font-weight: 600; }
+
+/* Avatar */
+.community-avatar { border-radius: 50%; object-fit: cover; flex-shrink: 0; display: block; }
+.community-avatar--initials { background: #2a2518; display: grid; place-items: center; color: var(--brand-warm-white, #f0ead8); font-weight: 700; font-family: var(--brand-font-body, sans-serif); line-height: 1; }
+
+/* Compose card */
+.community-compose-card { display: flex; gap: 0.85rem; padding: 1.1rem 1.25rem; background: var(--brand-card-bg, #11100d); border: 1px solid var(--brand-border, #2a2518); border-radius: 14px; margin-bottom: 1.25rem; }
+.community-compose-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.6rem; }
+.community-compose-tag { display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; color: var(--brand-muted, #b0a48c); }
+.community-compose-tag strong { color: var(--brand-gold, #c9a84c); }
+.community-compose-tag-clear { background: none; border: none; color: var(--brand-muted, #b0a48c); cursor: pointer; font-size: 1rem; line-height: 1; padding: 0 0.1rem; }
+.community-compose-textarea { width: 100%; padding: 0.7rem 0; background: transparent; border: none; border-bottom: 1px solid var(--brand-border, #2a2518); color: var(--brand-warm-white, #f0ead8); font-family: var(--brand-font-body, sans-serif); font-size: 0.975rem; resize: none; overflow: hidden; outline: none; box-sizing: border-box; transition: border-color 0.15s; }
+.community-compose-textarea:focus { border-bottom-color: var(--brand-gold, #c9a84c); }
+.community-compose-textarea::placeholder { color: var(--brand-muted, #b0a48c); }
+.community-compose-footer { display: flex; justify-content: flex-end; }
+.community-post-btn { font-size: 0.875rem; padding: 0.4rem 1.1rem; border-radius: 999px; }
+
+/* Status */
+.community-status-msg { color: var(--brand-muted, #b0a48c); font-size: 0.9rem; padding: 0.75rem 0; margin: 0; }
+.community-status-msg--error { color: #e07060; }
+
+/* Post list */
+.community-posts-list { display: flex; flex-direction: column; }
+
+/* Post card */
+.community-post-card { display: flex; gap: 0.75rem; padding: 1rem 0; border-bottom: 1px solid var(--brand-border, #2a2518); opacity: 1; transition: opacity 300ms; }
+.community-post-card--pending { opacity: 0.55; }
+.community-post-card:last-child { border-bottom: none; }
+.community-post-left { display: flex; flex-direction: column; align-items: center; gap: 0; padding-top: 0.15rem; }
+.community-thread-line { flex: 1; width: 2px; background: var(--brand-border, #2a2518); margin-top: 0.35rem; border-radius: 1px; min-height: 1rem; }
+.community-post-body { flex: 1; min-width: 0; }
+.community-post-header { display: flex; align-items: baseline; gap: 0.55rem; flex-wrap: wrap; margin-bottom: 0.25rem; }
+.community-post-author { font-weight: 700; font-size: 0.925rem; color: var(--brand-warm-white, #f0ead8); }
+.community-post-time { font-size: 0.8rem; color: var(--brand-muted, #b0a48c); }
+.community-lesson-chip { font-size: 0.73rem; font-weight: 500; padding: 0.15rem 0.55rem; border-radius: 999px; border: 1px solid var(--brand-border, #2a2518); background: transparent; color: var(--brand-muted, #b0a48c); cursor: pointer; transition: color 0.15s, border-color 0.15s; white-space: nowrap; }
+.community-lesson-chip:hover { color: var(--brand-gold, #c9a84c); border-color: var(--brand-gold, #c9a84c); }
+.community-post-text { font-size: 0.95rem; line-height: 1.6; color: var(--brand-warm-white, #f0ead8); white-space: pre-wrap; margin: 0 0 0.6rem; }
+
+/* Engagement bar */
+.community-engagement-bar { display: flex; align-items: center; gap: 0.15rem; margin-bottom: 0.25rem; }
+.community-action-btn { display: flex; align-items: center; gap: 0.3rem; background: none; border: none; color: var(--brand-muted, #b0a48c); cursor: pointer; padding: 0.3rem 0.6rem; border-radius: 999px; font-size: 0.82rem; font-weight: 500; transition: color 0.15s, background 0.15s; line-height: 1; }
+.community-action-btn:hover { color: var(--brand-gold, #c9a84c); background: rgba(201,168,76,0.08); }
+.community-action-btn--liked .community-action-icon { fill: #e05c5c; color: #e05c5c; }
+.community-action-btn--liked { color: #e05c5c; }
+.community-action-btn--liked:hover { background: rgba(224,92,92,0.08); color: #e05c5c; }
+.community-action-btn--active { color: var(--brand-gold, #c9a84c); }
+.community-action-icon { width: 17px; height: 17px; fill: currentColor; flex-shrink: 0; }
+
+/* Reply compose */
+.community-reply-compose { display: flex; gap: 0.65rem; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--brand-border, #2a2518); align-items: flex-start; }
+
+/* Replies thread */
+.community-replies { display: flex; flex-direction: column; gap: 0; margin-top: 0.5rem; padding-top: 0.5rem; }
+.community-reply { display: flex; gap: 0.6rem; padding: 0.65rem 0; border-top: 1px solid color-mix(in srgb, var(--brand-border, #2a2518) 60%, transparent); }
+.community-reply:first-child { border-top: none; }
+.community-reply-body { flex: 1; min-width: 0; }
+
+@media (max-width: 540px) {
+ .community-compose-card { padding: 0.9rem 1rem; }
+ .community-post-card { gap: 0.55rem; padding: 0.85rem 0; }
+}
diff --git a/src/colossiansStudy.tsx b/src/colossiansStudy.tsx
index 169ee6b..d8e5149 100644
--- a/src/colossiansStudy.tsx
+++ b/src/colossiansStudy.tsx
@@ -91,6 +91,8 @@ type StudyCommunityPost = {
message: string
createdAt: string
replies: StudyCommunityReply[]
+ likeCount?: number
+ likedByMe?: boolean
pending?: boolean
}
@@ -228,10 +230,31 @@ function getDisplayName(auth: StudyAuthState) {
return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
}
-function formatCommunityDate(value: string) {
+function formatRelativeTime(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
+ const diffMs = Date.now() - date.getTime()
+ const diffSec = Math.floor(diffMs / 1000)
+ if (diffSec < 60) return 'just now'
+ const diffMin = Math.floor(diffSec / 60)
+ if (diffMin < 60) return `${diffMin}m ago`
+ const diffHr = Math.floor(diffMin / 60)
+ if (diffHr < 24) return `${diffHr}h ago`
+ const diffDay = Math.floor(diffHr / 24)
+ if (diffDay < 7) return `${diffDay}d ago`
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
+}
+
+function UserAvatar({ name, url, size = 36 }: { name?: string; url?: string; size?: number }) {
+ if (url) {
+ return
+ }
+ const initial = (name || 'S').slice(0, 1).toUpperCase()
+ return (
+
+ {initial}
+
+ )
}
function CommunityBoard({
@@ -248,7 +271,6 @@ function CommunityBoard({
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
- const [message, setMessage] = useState('')
const [replyDrafts, setReplyDrafts] = useState>({})
const [replyingTo, setReplyingTo] = useState(null)
const [submitting, setSubmitting] = useState(false)
@@ -265,40 +287,25 @@ function CommunityBoard({
useEffect(() => {
let cancelled = false
-
async function load() {
- if (!canParticipate) {
- setPosts([])
- setLoading(false)
- return
- }
-
- setLoading(true)
- setError('')
+ if (!canParticipate) { setPosts([]); setLoading(false); return }
+ setLoading(true); setError('')
try {
const data = await readJson<{ posts?: StudyCommunityPost[] }>('/api/study-community?' + new URLSearchParams({ studySlug: study.slug }).toString())
- if (cancelled) return
- setPosts(Array.isArray(data.posts) ? data.posts : [])
+ if (!cancelled) setPosts(Array.isArray(data.posts) ? data.posts : [])
} catch (err) {
- if (cancelled) return
- setError(err instanceof Error ? err.message : 'Unable to load the community right now.')
+ if (!cancelled) setError(err instanceof Error ? err.message : 'Unable to load the community right now.')
} finally {
- if (cancelled) return
- setLoading(false)
+ if (!cancelled) setLoading(false)
}
}
-
void load()
- return () => {
- cancelled = true
- }
+ return () => { cancelled = true }
}, [canParticipate, study.slug])
async function submitPost() {
if (!newPost.trim() || !canParticipate) return
- setSubmitting(true)
- setMessage('')
- setError('')
+ setSubmitting(true); setError('')
const optimisticId = `pending-${Date.now()}`
const optimisticPost: StudyCommunityPost = {
id: optimisticId,
@@ -309,6 +316,8 @@ function CommunityBoard({
message: newPost,
createdAt: new Date().toISOString(),
replies: [],
+ likeCount: 0,
+ likedByMe: false,
pending: true,
}
setPosts(prev => [optimisticPost, ...prev])
@@ -321,9 +330,8 @@ function CommunityBoard({
body: JSON.stringify({ studySlug: study.slug, sectionId: activeSection, message: postedMessage }),
})
setPosts(prev => data.post
- ? prev.map(p => p.id === optimisticId ? { ...data.post!, pending: false } : p)
+ ? prev.map(p => p.id === optimisticId ? { ...data.post!, likeCount: 0, likedByMe: false, pending: false } : p)
: prev.filter(p => p.id !== optimisticId))
- setMessage('')
} catch (err) {
setPosts(prev => prev.filter(p => p.id !== optimisticId))
setNewPost(postedMessage)
@@ -336,8 +344,7 @@ function CommunityBoard({
async function submitReply(postId: string) {
const reply = replyDrafts[postId]?.trim()
if (!reply || !canParticipate) return
- setSubmitting(true)
- setError('')
+ setSubmitting(true); setError('')
try {
const data = await readJson<{ reply?: StudyCommunityReply }>(`/api/study-community/posts/${encodeURIComponent(postId)}/replies`, {
method: 'POST',
@@ -349,7 +356,6 @@ function CommunityBoard({
}
setReplyDrafts(prev => ({ ...prev, [postId]: '' }))
setReplyingTo(null)
- setMessage('Reply posted.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to post your reply.')
} finally {
@@ -357,34 +363,41 @@ function CommunityBoard({
}
}
+ async function toggleLike(postId: string) {
+ if (!canParticipate) return
+ setPosts(prev => prev.map(p => p.id === postId
+ ? { ...p, likedByMe: !p.likedByMe, likeCount: (p.likeCount ?? 0) + (p.likedByMe ? -1 : 1) }
+ : p))
+ try {
+ await readJson(`/api/study-community/posts/${encodeURIComponent(postId)}/like`, {
+ method: 'POST',
+ })
+ } catch {
+ setPosts(prev => prev.map(p => p.id === postId
+ ? { ...p, likedByMe: !p.likedByMe, likeCount: (p.likeCount ?? 0) + (p.likedByMe ? -1 : 1) }
+ : p))
+ }
+ }
+
return (
-
+
{/* Section filter tabs */}
{releasedSections.length > 1 && canParticipate && (
-
-
setActiveSection('')}
- >All lessons
+
+ setActiveSection('')}>All
{releasedSections.map(s => (
- setActiveSection(s.id)}
- >{s.title}
+ setActiveSection(s.id)}>{s.title}
))}
)}
- {!auth.checked &&
Checking your account status...
}
+ {!auth.checked &&
Checking your account status...
}
{auth.checked && !auth.authenticated && (
Sign in to join the study community.
Create Account
- My Account
+ Sign In
)}
@@ -398,112 +411,131 @@ function CommunityBoard({
)}
{canParticipate && (
-
- {activeSection && (
-
- Posting to: {releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'}
-
- )}
-
setNewPost(e.target.value)}
- onInput={e => {
- const el = e.currentTarget
- el.style.height = 'auto'
- el.style.height = `${el.scrollHeight}px`
- }}
- placeholder={activeSection ? `Share a thought about ${releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'}...` : 'Share a thought with the community...'}
- style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem', resize: 'none', overflow: 'hidden', boxSizing: 'border-box' }}
- />
-
-
- {submitting ? 'Posting...' : 'Post to Community'}
-
+
+
+
+ {activeSection && (
+
+ Posting to:
+ {releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'}
+ setActiveSection('')} aria-label="Clear lesson filter">×
+
+ )}
+
setNewPost(e.target.value)}
+ onInput={e => { const el = e.currentTarget; el.style.height = 'auto'; el.style.height = `${el.scrollHeight}px` }}
+ placeholder={activeSection ? `Share a thought about ${releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'}…` : 'Share a thought with the community…'}
+ className="community-compose-textarea"
+ />
+
+
+ {submitting ? 'Posting…' : 'Post'}
+
+
)}
- {message &&
{message}
}
- {error &&
{error}
}
+ {error &&
{error}
}
{canParticipate && (
-
+
{loading ? (
-
Loading community posts...
+
Loading posts…
) : visiblePosts.length === 0 ? (
-
No posts yet{activeSection ? ' for this lesson' : ''}. Be the first to start the conversation.
+
No posts yet{activeSection ? ' for this lesson' : ''}. Be the first to start the conversation.
) : visiblePosts.map(post => {
const postSection = study.sections.find(s => s.id === post.sectionId)
+ const isReplying = replyingTo === post.id
return (
-
-
-
- {post.authorAvatarUrl ? (
-
- ) : (
-
- {post.authorName?.slice(0, 1).toUpperCase() || 'S'}
-
- )}
-
{post.authorName || 'Student'}
+
+
+
+ {(post.replies?.length ?? 0) > 0 &&
}
+
+
+ {/* Header */}
+
+ {post.authorName || 'Student'}
+ {formatRelativeTime(post.createdAt)}
{postSection && !activeSection && (
- setActiveSection(postSection.id)}
- >{postSection.title}
+ setActiveSection(postSection.id)}>{postSection.title}
)}
-
{formatCommunityDate(post.createdAt)}
-
- {post.message}
-
- setReplyingTo(curr => curr === post.id ? null : post.id)}>
- {replyingTo === post.id ? 'Cancel' : `Reply${post.replies?.length ? ` (${post.replies.length})` : ''}`}
-
-
- {replyingTo === post.id && canParticipate && (
-
-
setReplyDrafts(prev => ({ ...prev, [post.id]: e.target.value }))}
- onInput={e => {
- const el = e.currentTarget
- el.style.height = 'auto'
- el.style.height = `${el.scrollHeight}px`
- }}
- placeholder={`Reply to ${post.authorName || communityDisplayName}...`}
- style={{ width: '100%', padding: '0.8rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem', resize: 'none', overflow: 'hidden' }}
- />
- submitReply(post.id)}>Post Reply
+ {/* Content */}
+ {post.message}
+ {/* Engagement bar */}
+
+
toggleLike(post.id)}
+ aria-label={post.likedByMe ? 'Unlike' : 'Like'}
+ disabled={post.pending}
+ >
+
+
+
+ {(post.likeCount ?? 0) > 0 && {post.likeCount} }
+
+
setReplyingTo(curr => curr === post.id ? null : post.id)}
+ aria-label="Reply"
+ disabled={post.pending}
+ >
+
+
+
+ {(post.replies?.length ?? 0) > 0 && {post.replies!.length} }
+
- )}
- {Array.isArray(post.replies) && post.replies.length > 0 && (
-
- {post.replies.map(reply => (
-
-
-
- {reply.authorAvatarUrl ? (
-
- ) : (
-
- {reply.authorName?.slice(0, 1).toUpperCase() || 'S'}
+ {/* Reply compose */}
+ {isReplying && (
+
+
+
+
setReplyDrafts(prev => ({ ...prev, [post.id]: e.target.value }))}
+ onInput={e => { const el = e.currentTarget; el.style.height = 'auto'; el.style.height = `${el.scrollHeight}px` }}
+ placeholder={`Reply to ${post.authorName || 'this post'}…`}
+ className="community-compose-textarea"
+ />
+
+ submitReply(post.id)}>Reply
+ setReplyingTo(null)}>Cancel
- )}
- {reply.authorName || 'Student'}
-
-
{formatCommunityDate(reply.createdAt)}
-
-
{reply.message}
- ))}
-
- )}
-
+
+ )}
+ {/* Replies */}
+ {Array.isArray(post.replies) && post.replies.length > 0 && (
+
+ {post.replies.map(reply => (
+
+
+
+
+ {reply.authorName || 'Student'}
+ {formatRelativeTime(reply.createdAt)}
+
+
{reply.message}
+
+
+ ))}
+
+ )}
+
+
)
})}
From 53e200174f768f8c5fe96a8c64fad7435abaabe4 Mon Sep 17 00:00:00 2001
From: nmemmert
Date: Thu, 3 Sep 2026 16:38:38 -0400
Subject: [PATCH 5/6] Add community shortcut to lesson sidebar; v1.2.7
Adds a "Discuss this lesson" link in the classroom sidebar that takes
the user directly to the community page pre-filtered to the current
lesson's sectionId, so they can jump into discussion without navigating
back to the study index first.
Co-Authored-By: Claude Sonnet 4.6
---
package.json | 2 +-
src/App.css | 8 ++++++++
src/colossiansStudy.tsx | 11 +++++++++++
3 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 9e6d173..e9ade0e 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
- "version": "1.2.6",
+ "version": "1.2.7",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/App.css b/src/App.css
index 4ba82cc..5d3f188 100644
--- a/src/App.css
+++ b/src/App.css
@@ -12620,3 +12620,11 @@
.community-compose-card { padding: 0.9rem 1rem; }
.community-post-card { gap: 0.55rem; padding: 0.85rem 0; }
}
+
+/* ── Community sidebar shortcut ─────────────────────────────────────────── */
+.study-class-side-block--community { padding: 0; border: 1px solid rgba(201,168,76,0.2); border-radius: 10px; overflow: hidden; }
+.study-community-shortcut { display: flex; align-items: center; gap: 0.6rem; padding: 0.75rem 1rem; color: var(--brand-gold, #c9a84c); font-size: 0.9rem; font-weight: 600; text-decoration: none; background: rgba(201,168,76,0.05); transition: background 0.15s; }
+.study-community-shortcut:hover { background: rgba(201,168,76,0.12); }
+.study-community-shortcut-icon { width: 16px; height: 16px; fill: currentColor; flex-shrink: 0; }
+.study-community-shortcut-arrow { margin-left: auto; opacity: 0.6; transition: transform 0.15s; }
+.study-community-shortcut:hover .study-community-shortcut-arrow { transform: translateX(3px); opacity: 1; }
diff --git a/src/colossiansStudy.tsx b/src/colossiansStudy.tsx
index d8e5149..83e3d0c 100644
--- a/src/colossiansStudy.tsx
+++ b/src/colossiansStudy.tsx
@@ -2266,6 +2266,17 @@ export function ColossiansStudySectionPage({ content }: Props) {
)}
+ {/* Community shortcut */}
+ {isEnrolled && (
+
+
+
+ Discuss this lesson
+ →
+
+
+ )}
+
{/* Greek Word Study */}
{section.greekNotes.length > 0 && (
From e35e5d55bb1a70ce86970575372698ec3ea46772 Mon Sep 17 00:00:00 2001
From: nmemmert
Date: Thu, 3 Sep 2026 16:41:18 -0400
Subject: [PATCH 6/6] Bump to v2.0.0
Co-Authored-By: Claude Sonnet 4.6
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index e9ade0e..7867ecd 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
- "version": "1.2.7",
+ "version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",