Merge feature/show-notes-form into main — v2.0.0
Publish Container / docker (push) Successful in 13m47s

- Structured show notes form for Episode Highlights (v1.2.3)
- Study account engagement: streaks, re-engagement reminders (v1.2.4)
- Redesigned study lesson UI: progress bar, sidebar nav, inline answers (v1.2.5)
- Social media community feed: likes, threading, relative timestamps (v1.2.6)
- Community shortcut in lesson sidebar (v1.2.7)
- v2.0.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-03 16:44:46 -04:00
commit 274adcc093
14 files changed
+1230 -256

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "1.2.2", "version": "2.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+10 -1
View File
@@ -31,7 +31,7 @@ import {
refreshContentCaches, refreshContentCaches,
queueHitStatsWrite, queueHitStatsWrite,
} from './server/data.js' } from './server/data.js'
import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail } from './server/email.js' import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail, sendStudyWeeklyDigestEmail } from './server/email.js'
import { import {
detectBot, detectBot,
sanitizeUserAgent, sanitizeUserAgent,
@@ -39,6 +39,7 @@ import {
recordHit, recordHit,
scheduleStudyReminders, scheduleStudyReminders,
scheduleReengagementEmails, scheduleReengagementEmails,
scheduleWeeklyDigestEmails,
} from './server/study-helpers.js' } from './server/study-helpers.js'
import { recordVisitor } from './server/routes/analytics.js' import { recordVisitor } from './server/routes/analytics.js'
@@ -194,6 +195,14 @@ Promise.all([
}, 60 * 60 * 1000) }, 60 * 60 * 1000)
void scheduleReengagementEmails(sendStudyReengagementEmail) 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, () => { app.listen(PORT, () => {
logResendEmailAlignmentWarnings() logResendEmailAlignmentWarnings()
console.log(`Siteforge listening on http://localhost:${PORT}`) console.log(`Siteforge listening on http://localhost:${PORT}`)
+9
View File
@@ -1415,6 +1415,9 @@ export function sanitizeStudyUsers(value) {
totpEnabledAt: typeof item?.totpEnabledAt === 'string' ? item.totpEnabledAt : null, totpEnabledAt: typeof item?.totpEnabledAt === 'string' ? item.totpEnabledAt : null,
totpRecoveryCodes: Array.isArray(item?.totpRecoveryCodes) ? item.totpRecoveryCodes.filter(h => typeof h === 'string') : [], totpRecoveryCodes: Array.isArray(item?.totpRecoveryCodes) ? item.totpRecoveryCodes.filter(h => typeof h === 'string') : [],
totpSecretPending: typeof item?.totpSecretPending === 'string' ? item.totpSecretPending : undefined, 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) 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()] = { progress.byStudy[studySlug.trim().toLowerCase()] = {
completedSectionIds: Array.from(new Set(completedSectionIds)), completedSectionIds: Array.from(new Set(completedSectionIds)),
bookmarkedSectionIds,
quizAnswers, quizAnswers,
} }
} }
@@ -1516,6 +1523,7 @@ export function sanitizeStudyCommunityPosts(value) {
? item.replies.map(sanitizeStudyCommunityReply).filter(Boolean).slice(0, 50) ? item.replies.map(sanitizeStudyCommunityReply).filter(Boolean).slice(0, 50)
: [] : []
if (!studySlug || !message) return null if (!studySlug || !message) return null
const likedBy = Array.isArray(item.likedBy) ? item.likedBy.filter(id => typeof id === 'string' && id.trim()).slice(0, 1000) : []
return { return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
studySlug, studySlug,
@@ -1525,6 +1533,7 @@ export function sanitizeStudyCommunityPosts(value) {
message, message,
createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(), createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
replies, replies,
likedBy,
} }
}) })
.filter(Boolean) .filter(Boolean)
+77
View File
@@ -635,3 +635,80 @@ export async function sendStudyReengagementEmail(email, displayName, studyTitle,
console.error('[study-reengagement] send exception:', err) 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 = (
`<p style="margin:0 0 16px;">Congratulations, <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>!</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>` +
`<p style="margin:0 0 16px;color:#c9a84c;font-weight:700;">${escapeHtml(studyTitle)}</p>`
)
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(signoff).replace(/\n/g, '<br/>')}</p>`
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 =>
`<p style="margin:0 0 12px;"><a href="${escapeHtml(l.sectionUrl)}" style="color:#c9a84c;text-decoration:none;font-weight:700;">${escapeHtml(l.sectionTitle)}</a>` +
(l.sectionReference ? ` <span style="color:#7a7060;">(${escapeHtml(l.sectionReference)})</span>` : '') +
(l.studyTitle ? ` <span style="color:#7a7060;font-size:0.9em;">— ${escapeHtml(l.studyTitle)}</span>` : '') +
`</p>`
).join('')
const lessonListText = lessons.map(l => `• ${l.sectionTitle}${l.sectionReference ? ` (${l.sectionReference})` : ''}: ${l.sectionUrl}`).join('\n')
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 20px;">${escapeHtml(introText)}</p>` +
`<div style="border-left:2px solid #2a2518;padding-left:16px;">${lessonListHtml}</div>`
)
const unsubLine = unsubUrl ? `<p style="margin:16px 0 0;font-size:11px;color:#7a7060;">Not interested? <a href="${escapeHtml(unsubUrl)}" style="color:#7a7060;">Unsubscribe from these reminders.</a></p>` : ''
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(signoff).replace(/\n/g, '<br/>')}</p>${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)
}
}
+52 -1
View File
@@ -9,6 +9,7 @@ import {
readUploadsMetadata, readUploadsMetadata,
writeUploadsMetadata, writeUploadsMetadata,
getUserNotesFilePath, getUserNotesFilePath,
loadUserProgress,
queueStudyUsersWrite, queueStudyUsersWrite,
} from '../data.js' } from '../data.js'
import { import {
@@ -16,7 +17,9 @@ import {
getStudyCatalog, getStudyCatalog,
normalizeStudySlug, normalizeStudySlug,
isEnrollableStudySlug, isEnrollableStudySlug,
makeUnsubUrl,
} from '../study-helpers.js' } from '../study-helpers.js'
import { sendStudyReengagementEmail } from '../email.js'
export function register(app) { export function register(app) {
app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => { 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 noteCount = Object.values(notes).filter(n => typeof n === 'string' && n.trim()).length
} catch { /* ignore */ } } 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 enrolledStudies = (user.enrolledStudySlugs ?? []).map(slug => {
const study = catalog.find(s => s.slug === 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 { return {
@@ -132,12 +145,50 @@ export function register(app) {
noteCount, noteCount,
subscribeNewsletter: user.subscribeNewsletter !== false, subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true, 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 }) 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) => { app.patch('/api/admin/study-users/:id', requireAdminAuth, (req, res) => {
const user = state.studyUsers.find(u => u.id === req.params.id) const user = state.studyUsers.find(u => u.id === req.params.id)
if (!user) { res.status(404).json({ message: 'User not found.' }); return } if (!user) { res.status(404).json({ message: 'User not found.' }); return }
+23 -1
View File
@@ -218,6 +218,8 @@ export function register(app) {
displayName: user.displayName ?? '', displayName: user.displayName ?? '',
subscribeNewsletter: user.subscribeNewsletter !== false, subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true, studyRemindersEnabled: user.studyRemindersEnabled === true,
reminderFrequency: user.reminderFrequency ?? 'immediate',
remindersPausedUntil: user.remindersPausedUntil ?? null,
avatarUrl: getStudyAvatarUrl(user), avatarUrl: getStudyAvatarUrl(user),
}, },
stats: { stats: {
@@ -303,6 +305,20 @@ export function register(app) {
const studyRemindersEnabled = req.body?.studyRemindersEnabled === true const studyRemindersEnabled = req.body?.studyRemindersEnabled === true
user.subscribeNewsletter = subscribeNewsletter user.subscribeNewsletter = subscribeNewsletter
user.studyRemindersEnabled = studyRemindersEnabled 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() user.updatedAt = new Date().toISOString()
queueStudyUsersWrite() 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)) 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) => { app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => {
+5
View File
@@ -4,6 +4,8 @@ import { requireAdminAuth } from '../auth.js'
import { requireStudyAuth, normalizeStudySlug, isStudyUserEnrolled, getStudyTitleBySlug } from '../study-helpers.js' import { requireStudyAuth, normalizeStudySlug, isStudyUserEnrolled, getStudyTitleBySlug } from '../study-helpers.js'
import { state } from '../state.js' import { state } from '../state.js'
import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js' import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js'
import { sendStudyCertificateEmail, getCanonicalBaseUrl } from '../email.js'
import { buildAbsoluteUrl } from '../helpers.js'
const publicCertRateLimiter = rateLimit({ const publicCertRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
@@ -95,6 +97,9 @@ export function register(app) {
} }
state.studyCertificates.push(cert) state.studyCertificates.push(cert)
queueStudyCertificatesWrite() 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 }) res.json({ ok: true, token: cert.token, issuedAt: cert.issuedAt })
+63 -1
View File
@@ -171,6 +171,7 @@ export function register(app) {
const progress = await loadUserProgress(user.id) const progress = await loadUserProgress(user.id)
const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? [] const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
const bookmarkedSectionIds = progress.byStudy[studySlug]?.bookmarkedSectionIds ?? []
// Record first visit timestamp on the user record (additive, never overwrite) // Record first visit timestamp on the user record (additive, never overwrite)
if (!user.firstVisitAt) { if (!user.firstVisitAt) {
@@ -178,7 +179,7 @@ export function register(app) {
queueStudyUsersWrite() queueStudyUsersWrite()
} }
res.json({ studySlug, completedSectionIds }) res.json({ studySlug, completedSectionIds, bookmarkedSectionIds })
}) })
app.post('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => { 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 }) 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 ───────────────────────────────────────────────────────────── // ── Community ─────────────────────────────────────────────────────────────
app.get('/api/study-community', requireStudyAuth, async (req, res) => { app.get('/api/study-community', requireStudyAuth, async (req, res) => {
@@ -323,9 +363,12 @@ export function register(app) {
.slice(0, 50) .slice(0, 50)
.map(post => { .map(post => {
const author = findStudyUserById(post.authorUserId) const author = findStudyUserById(post.authorUserId)
const likedBy = Array.isArray(post.likedBy) ? post.likedBy : []
return { return {
...post, ...post,
authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''), authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''),
likedByMe: likedBy.includes(user.id),
likeCount: likedBy.length,
replies: Array.isArray(post.replies) replies: Array.isArray(post.replies)
? post.replies.map(reply => { ? post.replies.map(reply => {
const replyAuthor = findStudyUserById(reply.authorUserId) const replyAuthor = findStudyUserById(reply.authorUserId)
@@ -412,4 +455,23 @@ export function register(app) {
res.json({ ok: true, reply }) 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) })
})
} }
+80 -1
View File
@@ -622,6 +622,9 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) {
for (const user of state.studyUsers) { for (const user of state.studyUsers) {
if (user.studyRemindersEnabled !== true) continue 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 email = user.username
const displayName = user.displayName || email const displayName = user.displayName || email
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [] 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 })) 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 secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge'
const token = createHmac('sha256', secret).update(userId).digest('hex') const token = createHmac('sha256', secret).update(userId).digest('hex')
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/' const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
@@ -694,6 +697,8 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
for (const user of state.studyUsers) { for (const user of state.studyUsers) {
if (user.studyRemindersEnabled !== true) continue if (user.studyRemindersEnabled !== true) continue
if (!user.lastLoginAt) 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) const lastLogin = new Date(user.lastLoginAt)
if (isNaN(lastLogin.getTime())) continue if (isNaN(lastLogin.getTime())) continue
const daysSinceLogin = (nowMs - lastLogin.getTime()) / (1000 * 60 * 60 * 24) 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()
}
}
+126 -4
View File
@@ -78,6 +78,9 @@ interface StudyUserRecord {
noteCount: number noteCount: number
subscribeNewsletter: boolean subscribeNewsletter: boolean
studyRemindersEnabled: boolean studyRemindersEnabled: boolean
reminderFrequency: 'immediate' | 'weekly'
remindersPausedUntil: string | null
reengagementSentAt: Record<string, string>
} }
function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
@@ -89,6 +92,7 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
const [newPassword, setNewPassword] = useState<Record<string, string>>({}) const [newPassword, setNewPassword] = useState<Record<string, string>>({})
const [msg, setMsg] = useState<Record<string, string>>({}) const [msg, setMsg] = useState<Record<string, string>>({})
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null) const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
const [reengagementTier, setReengagementTier] = useState<Record<string, string>>({})
function flashMsg(id: string, text: string) { function flashMsg(id: string, text: string) {
setMsg(prev => ({ ...prev, [id]: text })) 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') } } 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) { async function handleDelete(id: string) {
try { try {
await fetch(`/api/admin/study-users/${id}`, { method: 'DELETE' }) await fetch(`/api/admin/study-users/${id}`, { method: 'DELETE' })
@@ -222,7 +240,13 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
<span>Last studied: {user.lastStudiedDate ?? '—'}</span> <span>Last studied: {user.lastStudiedDate ?? '—'}</span>
<span>Streak: {user.currentStreak ?? 0} 🔥 (best: {user.longestStreak ?? 0})</span> <span>Streak: {user.currentStreak ?? 0} 🔥 (best: {user.longestStreak ?? 0})</span>
<span>Newsletter: {user.subscribeNewsletter ? 'Yes' : 'No'}</span> <span>Newsletter: {user.subscribeNewsletter ? 'Yes' : 'No'}</span>
<span>Reminders: {user.studyRemindersEnabled ? 'On' : 'Off'}</span> <span>Reminders: {user.studyRemindersEnabled ? 'On' : 'Off'} · {user.reminderFrequency === 'weekly' ? 'Weekly digest' : 'Per-lesson'}</span>
{user.remindersPausedUntil && new Date(user.remindersPausedUntil) > new Date() && (
<span style={{ color: '#c9a84c' }}>Paused until {new Date(user.remindersPausedUntil).toLocaleDateString()}</span>
)}
{user.reengagementSentAt && Object.keys(user.reengagementSentAt).length > 0 && (
<span>Re-engagement sent: {Object.keys(user.reengagementSentAt).sort().join(', ')}</span>
)}
</div> </div>
{/* Display Name */} {/* Display Name */}
@@ -292,6 +316,25 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
)} )}
</div> </div>
{/* Re-engagement */}
{user.enrolledStudies.length > 0 && (
<div className="admin-study-user-section">
<h4>Send Re-engagement Email</h4>
<div className="admin-study-user-row">
<select
value={reengagementTier[user.id] ?? 't1'}
onChange={e => setReengagementTier(prev => ({ ...prev, [user.id]: e.target.value }))}
>
<option value="t1">Tier 1 (gentle nudge)</option>
<option value="t2">Tier 2 (momentum)</option>
<option value="t3">Tier 3 (still here)</option>
</select>
<button type="button" className="btn-admin-save" onClick={() => handleSendReengagement(user)}>Send</button>
</div>
<p className="admin-stats-note" style={{ marginTop: '0.25rem' }}>Sends the selected tier email immediately, regardless of inactivity threshold.</p>
</div>
)}
{msg[user.id] && <p className="admin-study-user-msg">{msg[user.id]}</p>} {msg[user.id] && <p className="admin-study-user-msg">{msg[user.id]}</p>}
{/* Delete */} {/* Delete */}
@@ -419,6 +462,18 @@ function EmailTemplatesPanel({
fieldKeys={['studyReengagementDays','studyReengagementT1Subject','studyReengagementT1Body','studyReengagementT1Cta','studyReengagementT2Subject','studyReengagementT2Body','studyReengagementT2Cta','studyReengagementT3Subject','studyReengagementT3Body','studyReengagementT3Cta']} fieldKeys={['studyReengagementDays','studyReengagementT1Subject','studyReengagementT1Body','studyReengagementT1Cta','studyReengagementT2Subject','studyReengagementT2Body','studyReengagementT2Cta','studyReengagementT3Subject','studyReengagementT3Body','studyReengagementT3Cta']}
openKey="study-reengagement" /> openKey="study-reengagement" />
<EmailTemplateCard type="study" icon="🏅" title="Study Account — Completion Certificate"
trigger="A student earns their certificate by completing all lessons in a study"
autoVars="Student's name, study title, and a link to the public certificate are auto-inserted"
fieldKeys={['studyCertificateEmailSubject','studyCertificateEmailBody','studyCertificateEmailCtaLabel','studyCertificateEmailSignoff']}
openKey="study-certificate" />
<EmailTemplateCard type="study" icon="📅" title="Study Account — Weekly Lesson Digest"
trigger="Students who choose 'Weekly Digest' mode receive one summary email per week listing all newly released lessons"
autoVars="Student's name, lesson list (title, scripture reference, link), and a one-click unsubscribe link"
fieldKeys={['studyWeeklyDigestEmailSubject','studyWeeklyDigestEmailBody','studyWeeklyDigestEmailSignoff']}
openKey="study-weekly-digest" />
<EmailTemplateCard type="transactional" icon="🔐" title="Account Security — Email Change Confirmation" <EmailTemplateCard type="transactional" icon="🔐" title="Account Security — Email Change Confirmation"
trigger="A student requests to change their account email address" trigger="A student requests to change their account email address"
autoVars="Button links to a one-time verification URL (auto-generated per request, not editable)" autoVars="Button links to a one-time verification URL (auto-generated per request, not editable)"
@@ -927,6 +982,13 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect
{ key: 'studyReengagementT3Subject', label: 'Tier 3 — Subject Line', section: 'email-templates' }, { key: 'studyReengagementT3Subject', label: 'Tier 3 — Subject Line', section: 'email-templates' },
{ key: 'studyReengagementT3Body', label: 'Tier 3 — Body', multiline: true, section: 'email-templates' }, { key: 'studyReengagementT3Body', label: 'Tier 3 — Body', multiline: true, section: 'email-templates' },
{ key: 'studyReengagementT3Cta', label: 'Tier 3 — Button Label', section: 'email-templates' }, { key: 'studyReengagementT3Cta', label: 'Tier 3 — Button Label', section: 'email-templates' },
{ key: 'studyCertificateEmailSubject', label: 'Subject Line', section: 'email-templates' },
{ key: 'studyCertificateEmailBody', label: 'Body Paragraph', multiline: true, section: 'email-templates' },
{ key: 'studyCertificateEmailCtaLabel', label: 'Button Label', section: 'email-templates' },
{ key: 'studyCertificateEmailSignoff', label: 'Signoff', multiline: true, section: 'email-templates' },
{ key: 'studyWeeklyDigestEmailSubject', label: 'Subject Line', section: 'email-templates' },
{ key: 'studyWeeklyDigestEmailBody', label: 'Intro Paragraph', multiline: true, section: 'email-templates' },
{ key: 'studyWeeklyDigestEmailSignoff', label: 'Signoff', multiline: true, section: 'email-templates' },
{ key: 'emailChangeSubject', label: 'Subject Line', section: 'email-templates' }, { key: 'emailChangeSubject', label: 'Subject Line', section: 'email-templates' },
{ key: 'emailChangeBody', label: 'Body Text', multiline: true, section: 'email-templates' }, { key: 'emailChangeBody', label: 'Body Text', multiline: true, section: 'email-templates' },
{ key: 'emailChangeCtaLabel', label: 'Button Label', section: 'email-templates' }, { key: 'emailChangeCtaLabel', label: 'Button Label', section: 'email-templates' },
@@ -1642,7 +1704,11 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
summary: '', summary: '',
url: '', url: '',
embedUrl: '', embedUrl: '',
showNotes: '', showNotesSummary: '',
showNotesThemes: [],
showNotesScripture: [],
showNotesQuotes: [],
showNotesResources: [],
discussionQuestions: [], discussionQuestions: [],
}, },
], ],
@@ -1656,6 +1722,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) { function updatePodcastLinkQuestions(id: string, raw: string) {
const questions = raw.split('\n').map(q => q.trim()).filter(Boolean) const questions = raw.split('\n').map(q => q.trim()).filter(Boolean)
setForm(f => ({ setForm(f => ({
@@ -3440,8 +3516,54 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
</select> </select>
</div> </div>
<div className="admin-field"> <div className="admin-field">
<label htmlFor={`podcast-notes-${activeHighlight.id}`}>Show Notes</label> <label htmlFor={`podcast-notes-summary-${activeHighlight.id}`}>Episode Summary</label>
<textarea id={`podcast-notes-${activeHighlight.id}`} rows={4} placeholder="Key points, scripture references, timestamps..." value={activeHighlight.showNotes ?? ''} onChange={e => updatePodcastLink(activeHighlight.id, 'showNotes', e.target.value)} /> <textarea
id={`podcast-notes-summary-${activeHighlight.id}`}
rows={3}
placeholder="2–3 sentences summarizing the episode..."
value={activeHighlight.showNotesSummary ?? (activeHighlight.showNotes && !(activeHighlight.showNotesThemes?.length) ? activeHighlight.showNotes : '')}
onChange={e => updatePodcastLink(activeHighlight.id, 'showNotesSummary', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor={`podcast-notes-themes-${activeHighlight.id}`}>Key Themes (one per line)</label>
<textarea
id={`podcast-notes-themes-${activeHighlight.id}`}
rows={3}
placeholder="Faith and doubt&#10;Prayer in suffering&#10;Community"
value={(activeHighlight.showNotesThemes ?? []).join('\n')}
onChange={e => updatePodcastLinkStringArray(activeHighlight.id, 'showNotesThemes', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor={`podcast-notes-scripture-${activeHighlight.id}`}>Scripture References (one per line)</label>
<textarea
id={`podcast-notes-scripture-${activeHighlight.id}`}
rows={3}
placeholder="Romans 8:28&#10;Psalm 23:4"
value={(activeHighlight.showNotesScripture ?? []).join('\n')}
onChange={e => updatePodcastLinkStringArray(activeHighlight.id, 'showNotesScripture', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor={`podcast-notes-quotes-${activeHighlight.id}`}>Key Quotes (one per line)</label>
<textarea
id={`podcast-notes-quotes-${activeHighlight.id}`}
rows={3}
placeholder="God meets us in the middle of our mess."
value={(activeHighlight.showNotesQuotes ?? []).join('\n')}
onChange={e => updatePodcastLinkStringArray(activeHighlight.id, 'showNotesQuotes', e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor={`podcast-notes-resources-${activeHighlight.id}`}>Resources &amp; Links (one per line)</label>
<textarea
id={`podcast-notes-resources-${activeHighlight.id}`}
rows={3}
placeholder="Book: The Ruthless Elimination of Hurry by John Mark Comer&#10;https://example.com"
value={(activeHighlight.showNotesResources ?? []).join('\n')}
onChange={e => updatePodcastLinkStringArray(activeHighlight.id, 'showNotesResources', e.target.value)}
/>
</div> </div>
<div className="admin-field"> <div className="admin-field">
<label htmlFor={`podcast-dq-${activeHighlight.id}`}>Discussion Questions (one per line)</label> <label htmlFor={`podcast-dq-${activeHighlight.id}`}>Discussion Questions (one per line)</label>
+264 -5
View File
@@ -2668,17 +2668,20 @@
.note-anchor-btn { .note-anchor-btn {
position: absolute; position: absolute;
top: 1rem; top: 0.85rem;
right: 1rem; right: 0.85rem;
background: transparent; background: transparent;
border: 1px solid #3a3828; border: 1px solid #3a3828;
border-radius: 6px; border-radius: 6px;
color: #5a5440; color: #5a5440;
padding: 0.2rem 0.55rem; padding: 0.22rem 0.65rem;
font-size: 0.9rem; font-size: 0.78rem;
font-family: var(--brand-font-body);
font-weight: 500;
letter-spacing: 0.02em;
cursor: pointer; cursor: pointer;
transition: color 150ms, border-color 150ms, background 150ms; transition: color 150ms, border-color 150ms, background 150ms;
line-height: 1; line-height: 1.4;
z-index: 1; z-index: 1;
} }
@@ -2699,6 +2702,184 @@
border-color: rgba(224, 192, 112, 0.5); 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 ─────────────────────────────────────────── */ /* ── Sidebar anchor list ─────────────────────────────────────────── */
.note-anchor-list { .note-anchor-list {
display: flex; display: flex;
@@ -12369,3 +12550,81 @@
.admin-save-ok { color: #6ebd7c; font-size: 0.85rem; margin: 0; } .admin-save-ok { color: #6ebd7c; font-size: 0.85rem; margin: 0; }
.admin-save-error { color: #e05c5c; 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; } .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; }
}
/* ── 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; }
+42 -2
View File
@@ -716,6 +716,11 @@ function PodcastHighlightsSection({ content }: { content: SiteContent }) {
|| isLikelySpotifyEpisodeUrl(link.url) || isLikelySpotifyEpisodeUrl(link.url)
|| (link.discussionQuestions ?? []).length > 0 || (link.discussionQuestions ?? []).length > 0
|| link.showNotes || 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) const dest = (!hasRichContent && link.url)
? { external: link.url } ? { external: link.url }
@@ -1500,10 +1505,45 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
</a> </a>
)} )}
{episode.showNotes && ( {(episode.showNotesSummary || (episode.showNotesThemes ?? []).length > 0 || (episode.showNotesScripture ?? []).length > 0 || (episode.showNotesQuotes ?? []).length > 0 || (episode.showNotesResources ?? []).length > 0 || episode.showNotes) && (
<div className="episode-detail-show-notes"> <div className="episode-detail-show-notes">
<h2 className="episode-detail-section-heading">Show Notes</h2> <h2 className="episode-detail-section-heading">Show Notes</h2>
<p>{episode.showNotes}</p> {episode.showNotesSummary
? <p>{episode.showNotesSummary}</p>
: episode.showNotes && !(episode.showNotesThemes ?? []).length && <p>{episode.showNotes}</p>
}
{(episode.showNotesThemes ?? []).length > 0 && (
<div className="episode-detail-show-notes-section">
<h3 className="episode-detail-show-notes-heading">Key Themes</h3>
<ul className="episode-detail-show-notes-list">
{episode.showNotesThemes!.map((t, i) => <li key={i}>{t}</li>)}
</ul>
</div>
)}
{(episode.showNotesScripture ?? []).length > 0 && (
<div className="episode-detail-show-notes-section">
<h3 className="episode-detail-show-notes-heading">Scripture References</h3>
<ul className="episode-detail-show-notes-list">
{episode.showNotesScripture!.map((s, i) => <li key={i}>{s}</li>)}
</ul>
</div>
)}
{(episode.showNotesQuotes ?? []).length > 0 && (
<div className="episode-detail-show-notes-section">
<h3 className="episode-detail-show-notes-heading">Key Quotes</h3>
{episode.showNotesQuotes!.map((q, i) => (
<blockquote key={i} className="episode-detail-show-notes-quote">{q}</blockquote>
))}
</div>
)}
{(episode.showNotesResources ?? []).length > 0 && (
<div className="episode-detail-show-notes-section">
<h3 className="episode-detail-show-notes-heading">Resources</h3>
<ul className="episode-detail-show-notes-list">
{episode.showNotesResources!.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div> </div>
)} )}
+455 -239
View File
@@ -44,6 +44,8 @@ type StudyAccountOverview = {
displayName: string displayName: string
subscribeNewsletter: boolean subscribeNewsletter: boolean
studyRemindersEnabled: boolean studyRemindersEnabled: boolean
reminderFrequency?: string
remindersPausedUntil?: string | null
avatarUrl?: string avatarUrl?: string
} }
stats: { stats: {
@@ -69,6 +71,7 @@ type StudyNotesMap = Record<string, string>
type StudyProgress = { type StudyProgress = {
completedSectionIds: string[] completedSectionIds: string[]
bookmarkedSectionIds: string[]
} }
type StudyCommunityReply = { type StudyCommunityReply = {
@@ -88,6 +91,8 @@ type StudyCommunityPost = {
message: string message: string
createdAt: string createdAt: string
replies: StudyCommunityReply[] replies: StudyCommunityReply[]
likeCount?: number
likedByMe?: boolean
pending?: boolean pending?: boolean
} }
@@ -225,10 +230,31 @@ function getDisplayName(auth: StudyAuthState) {
return auth.displayName?.trim() || auth.username.split('@')[0] || 'student' return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
} }
function formatCommunityDate(value: string) { function formatRelativeTime(value: string) {
const date = new Date(value) const date = new Date(value)
if (Number.isNaN(date.getTime())) return '' 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 <img src={url} alt={name || 'avatar'} width={size} height={size} className="community-avatar" />
}
const initial = (name || 'S').slice(0, 1).toUpperCase()
return (
<div className="community-avatar community-avatar--initials" style={{ width: size, height: size, fontSize: size * 0.4 }}>
{initial}
</div>
)
} }
function CommunityBoard({ function CommunityBoard({
@@ -245,7 +271,6 @@ function CommunityBoard({
const [posts, setPosts] = useState<StudyCommunityPost[]>([]) const [posts, setPosts] = useState<StudyCommunityPost[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [message, setMessage] = useState('')
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({}) const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({})
const [replyingTo, setReplyingTo] = useState<string | null>(null) const [replyingTo, setReplyingTo] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
@@ -262,40 +287,25 @@ function CommunityBoard({
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
async function load() { async function load() {
if (!canParticipate) { if (!canParticipate) { setPosts([]); setLoading(false); return }
setPosts([]) setLoading(true); setError('')
setLoading(false)
return
}
setLoading(true)
setError('')
try { try {
const data = await readJson<{ posts?: StudyCommunityPost[] }>('/api/study-community?' + new URLSearchParams({ studySlug: study.slug }).toString()) const data = await readJson<{ posts?: StudyCommunityPost[] }>('/api/study-community?' + new URLSearchParams({ studySlug: study.slug }).toString())
if (cancelled) return if (!cancelled) setPosts(Array.isArray(data.posts) ? data.posts : [])
setPosts(Array.isArray(data.posts) ? data.posts : [])
} catch (err) { } catch (err) {
if (cancelled) return if (!cancelled) setError(err instanceof Error ? err.message : 'Unable to load the community right now.')
setError(err instanceof Error ? err.message : 'Unable to load the community right now.')
} finally { } finally {
if (cancelled) return if (!cancelled) setLoading(false)
setLoading(false)
} }
} }
void load() void load()
return () => { return () => { cancelled = true }
cancelled = true
}
}, [canParticipate, study.slug]) }, [canParticipate, study.slug])
async function submitPost() { async function submitPost() {
if (!newPost.trim() || !canParticipate) return if (!newPost.trim() || !canParticipate) return
setSubmitting(true) setSubmitting(true); setError('')
setMessage('')
setError('')
const optimisticId = `pending-${Date.now()}` const optimisticId = `pending-${Date.now()}`
const optimisticPost: StudyCommunityPost = { const optimisticPost: StudyCommunityPost = {
id: optimisticId, id: optimisticId,
@@ -306,6 +316,8 @@ function CommunityBoard({
message: newPost, message: newPost,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
replies: [], replies: [],
likeCount: 0,
likedByMe: false,
pending: true, pending: true,
} }
setPosts(prev => [optimisticPost, ...prev]) setPosts(prev => [optimisticPost, ...prev])
@@ -318,9 +330,8 @@ function CommunityBoard({
body: JSON.stringify({ studySlug: study.slug, sectionId: activeSection, message: postedMessage }), body: JSON.stringify({ studySlug: study.slug, sectionId: activeSection, message: postedMessage }),
}) })
setPosts(prev => data.post 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)) : prev.filter(p => p.id !== optimisticId))
setMessage('')
} catch (err) { } catch (err) {
setPosts(prev => prev.filter(p => p.id !== optimisticId)) setPosts(prev => prev.filter(p => p.id !== optimisticId))
setNewPost(postedMessage) setNewPost(postedMessage)
@@ -333,8 +344,7 @@ function CommunityBoard({
async function submitReply(postId: string) { async function submitReply(postId: string) {
const reply = replyDrafts[postId]?.trim() const reply = replyDrafts[postId]?.trim()
if (!reply || !canParticipate) return if (!reply || !canParticipate) return
setSubmitting(true) setSubmitting(true); setError('')
setError('')
try { try {
const data = await readJson<{ reply?: StudyCommunityReply }>(`/api/study-community/posts/${encodeURIComponent(postId)}/replies`, { const data = await readJson<{ reply?: StudyCommunityReply }>(`/api/study-community/posts/${encodeURIComponent(postId)}/replies`, {
method: 'POST', method: 'POST',
@@ -346,7 +356,6 @@ function CommunityBoard({
} }
setReplyDrafts(prev => ({ ...prev, [postId]: '' })) setReplyDrafts(prev => ({ ...prev, [postId]: '' }))
setReplyingTo(null) setReplyingTo(null)
setMessage('Reply posted.')
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unable to post your reply.') setError(err instanceof Error ? err.message : 'Unable to post your reply.')
} finally { } finally {
@@ -354,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 ( return (
<div className="study-community-board"> <div className="community-feed">
{/* Section filter tabs */} {/* Section filter tabs */}
{releasedSections.length > 1 && canParticipate && ( {releasedSections.length > 1 && canParticipate && (
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1rem' }}> <div className="community-filter-bar">
<button <button type="button" className={`community-filter-chip${activeSection === '' ? ' community-filter-chip--active' : ''}`} onClick={() => setActiveSection('')}>All</button>
type="button"
className={`qa-topic-btn${activeSection === '' ? ' qa-topic-btn--active' : ''}`}
onClick={() => setActiveSection('')}
>All lessons</button>
{releasedSections.map(s => ( {releasedSections.map(s => (
<button <button key={s.id} type="button" className={`community-filter-chip${activeSection === s.id ? ' community-filter-chip--active' : ''}`} onClick={() => setActiveSection(s.id)}>{s.title}</button>
key={s.id}
type="button"
className={`qa-topic-btn${activeSection === s.id ? ' qa-topic-btn--active' : ''}`}
onClick={() => setActiveSection(s.id)}
>{s.title}</button>
))} ))}
</div> </div>
)} )}
{!auth.checked && <p className="study-note-status">Checking your account status...</p>} {!auth.checked && <p className="community-status-msg">Checking your account status...</p>}
{auth.checked && !auth.authenticated && ( {auth.checked && !auth.authenticated && (
<div className="study-auth-box"> <div className="study-auth-box">
<p className="study-auth-why">Sign in to join the study community.</p> <p className="study-auth-why">Sign in to join the study community.</p>
<div className="study-auth-actions"> <div className="study-auth-actions">
<Link to="/study/signup" className="btn-primary">Create Account</Link> <Link to="/study/signup" className="btn-primary">Create Account</Link>
<Link to="/study/account" className="btn-secondary">My Account</Link> <Link to="/study/account" className="btn-secondary">Sign In</Link>
</div> </div>
</div> </div>
)} )}
@@ -395,112 +411,131 @@ function CommunityBoard({
)} )}
{canParticipate && ( {canParticipate && (
<div style={{ marginBottom: '1.5rem' }}> <div className="community-compose-card">
<UserAvatar name={communityDisplayName} url={auth.avatarUrl || undefined} size={40} />
<div className="community-compose-body">
{activeSection && ( {activeSection && (
<p style={{ fontSize: '0.875rem', color: '#7a7060', marginBottom: '0.5rem' }}> <div className="community-compose-tag">
Posting to: <strong style={{ color: '#e0c070' }}>{releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'}</strong> <span>Posting to:</span>
</p> <strong>{releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'}</strong>
)} <button type="button" className="community-compose-tag-clear" onClick={() => setActiveSection('')} aria-label="Clear lesson filter">×</button>
<textarea
rows={4}
value={newPost}
onChange={e => 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' }}
/>
<div className="study-auth-actions">
<button type="button" className="btn-primary" disabled={submitting || !newPost.trim()} onClick={submitPost}>
{submitting ? 'Posting...' : 'Post to Community'}
</button>
</div>
</div> </div>
)} )}
{message && <p className="study-note-status" style={{ marginBottom: '0.75rem' }}>{message}</p>}
{error && <p className="study-note-status" style={{ marginBottom: '0.75rem' }}>{error}</p>}
{canParticipate && (
<div style={{ display: 'grid', gap: '0.85rem' }}>
{loading ? (
<p className="study-detail-copy">Loading community posts...</p>
) : visiblePosts.length === 0 ? (
<p className="study-detail-copy">No posts yet{activeSection ? ' for this lesson' : ''}. Be the first to start the conversation.</p>
) : visiblePosts.map(post => {
const postSection = study.sections.find(s => s.id === post.sectionId)
return (
<div key={post.id} style={{ border: '1px solid #2a2518', borderRadius: '14px', padding: '1.15rem', background: '#11100d', opacity: post.pending ? 0.65 : 1, transition: 'opacity 300ms' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap', marginBottom: '0.4rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}>
{post.authorAvatarUrl ? (
<img src={post.authorAvatarUrl} alt={`${post.authorName || 'Student'} avatar`} width={28} height={28} style={{ borderRadius: '50%', border: '1px solid #2a2518' }} />
) : (
<div style={{ width: 28, height: 28, borderRadius: '50%', background: '#2a2518', display: 'grid', placeItems: 'center', color: '#f0ead8', fontSize: '0.85rem', fontWeight: 700 }}>
{post.authorName?.slice(0, 1).toUpperCase() || 'S'}
</div>
)}
<strong style={{ color: '#e0c070' }}>{post.authorName || 'Student'}</strong>
{postSection && !activeSection && (
<button
type="button"
className="qa-topic-btn"
style={{ padding: '0.2rem 0.6rem', fontSize: '0.78rem' }}
onClick={() => setActiveSection(postSection.id)}
>{postSection.title}</button>
)}
</div>
<span className="study-note-status">{formatCommunityDate(post.createdAt)}</span>
</div>
<p className="study-detail-copy" style={{ marginTop: 0, whiteSpace: 'pre-wrap' }}>{post.message}</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '0.75rem' }}>
<button type="button" className="btn-secondary" style={{ fontSize: '0.85rem', padding: '0.35rem 0.85rem' }} onClick={() => setReplyingTo(curr => curr === post.id ? null : post.id)}>
{replyingTo === post.id ? 'Cancel' : `Reply${post.replies?.length ? ` (${post.replies.length})` : ''}`}
</button>
</div>
{replyingTo === post.id && canParticipate && (
<div style={{ marginTop: '0.85rem' }}>
<textarea <textarea
rows={3} rows={3}
value={newPost}
onChange={e => 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"
/>
<div className="community-compose-footer">
<button type="button" className="btn-primary community-post-btn" disabled={submitting || !newPost.trim()} onClick={submitPost}>
{submitting ? 'Posting…' : 'Post'}
</button>
</div>
</div>
</div>
)}
{error && <p className="community-status-msg community-status-msg--error">{error}</p>}
{canParticipate && (
<div className="community-posts-list">
{loading ? (
<p className="community-status-msg">Loading posts…</p>
) : visiblePosts.length === 0 ? (
<p className="community-status-msg">No posts yet{activeSection ? ' for this lesson' : ''}. Be the first to start the conversation.</p>
) : visiblePosts.map(post => {
const postSection = study.sections.find(s => s.id === post.sectionId)
const isReplying = replyingTo === post.id
return (
<article key={post.id} className={`community-post-card${post.pending ? ' community-post-card--pending' : ''}`}>
<div className="community-post-left">
<UserAvatar name={post.authorName} url={post.authorAvatarUrl} size={40} />
{(post.replies?.length ?? 0) > 0 && <div className="community-thread-line" />}
</div>
<div className="community-post-body">
{/* Header */}
<div className="community-post-header">
<span className="community-post-author">{post.authorName || 'Student'}</span>
<span className="community-post-time" title={post.createdAt}>{formatRelativeTime(post.createdAt)}</span>
{postSection && !activeSection && (
<button type="button" className="community-lesson-chip" onClick={() => setActiveSection(postSection.id)}>{postSection.title}</button>
)}
</div>
{/* Content */}
<p className="community-post-text">{post.message}</p>
{/* Engagement bar */}
<div className="community-engagement-bar">
<button
type="button"
className={`community-action-btn${post.likedByMe ? ' community-action-btn--liked' : ''}`}
onClick={() => toggleLike(post.id)}
aria-label={post.likedByMe ? 'Unlike' : 'Like'}
disabled={post.pending}
>
<svg viewBox="0 0 24 24" className="community-action-icon" aria-hidden="true">
<path d={post.likedByMe
? 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'
: 'M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z'}
/>
</svg>
{(post.likeCount ?? 0) > 0 && <span>{post.likeCount}</span>}
</button>
<button
type="button"
className={`community-action-btn${isReplying ? ' community-action-btn--active' : ''}`}
onClick={() => setReplyingTo(curr => curr === post.id ? null : post.id)}
aria-label="Reply"
disabled={post.pending}
>
<svg viewBox="0 0 24 24" className="community-action-icon" aria-hidden="true">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" />
</svg>
{(post.replies?.length ?? 0) > 0 && <span>{post.replies!.length}</span>}
</button>
</div>
{/* Reply compose */}
{isReplying && (
<div className="community-reply-compose">
<UserAvatar name={communityDisplayName} url={auth.avatarUrl || undefined} size={30} />
<div style={{ flex: 1 }}>
<textarea
rows={2}
autoFocus
value={replyDrafts[post.id] ?? ''} value={replyDrafts[post.id] ?? ''}
onChange={e => setReplyDrafts(prev => ({ ...prev, [post.id]: e.target.value }))} onChange={e => setReplyDrafts(prev => ({ ...prev, [post.id]: e.target.value }))}
onInput={e => { onInput={e => { const el = e.currentTarget; el.style.height = 'auto'; el.style.height = `${el.scrollHeight}px` }}
const el = e.currentTarget placeholder={`Reply to ${post.authorName || 'this post'}…`}
el.style.height = 'auto' className="community-compose-textarea"
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' }}
/> />
<button type="button" className="btn-primary" style={{ fontSize: '0.9rem' }} disabled={submitting || !(replyDrafts[post.id] ?? '').trim()} onClick={() => submitReply(post.id)}>Post Reply</button> <div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
<button type="button" className="btn-primary" style={{ fontSize: '0.875rem', padding: '0.35rem 0.9rem' }} disabled={submitting || !(replyDrafts[post.id] ?? '').trim()} onClick={() => submitReply(post.id)}>Reply</button>
<button type="button" className="btn-secondary" style={{ fontSize: '0.875rem', padding: '0.35rem 0.9rem' }} onClick={() => setReplyingTo(null)}>Cancel</button>
</div>
</div>
</div> </div>
)} )}
{/* Replies */}
{Array.isArray(post.replies) && post.replies.length > 0 && ( {Array.isArray(post.replies) && post.replies.length > 0 && (
<div style={{ marginTop: '1rem', display: 'grid', gap: '0.65rem' }}> <div className="community-replies">
{post.replies.map(reply => ( {post.replies.map(reply => (
<div key={reply.id} style={{ borderLeft: '2px solid #2a2518', paddingLeft: '0.85rem' }}> <div key={reply.id} className="community-reply">
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}> <UserAvatar name={reply.authorName} url={reply.authorAvatarUrl} size={28} />
<div style={{ display: 'flex', alignItems: 'center', gap: '0.55rem', flexWrap: 'wrap' }}> <div className="community-reply-body">
{reply.authorAvatarUrl ? ( <div className="community-post-header">
<img src={reply.authorAvatarUrl} alt={`${reply.authorName || 'Student'} avatar`} width={22} height={22} style={{ borderRadius: '50%', border: '1px solid #2a2518' }} /> <span className="community-post-author">{reply.authorName || 'Student'}</span>
) : ( <span className="community-post-time">{formatRelativeTime(reply.createdAt)}</span>
<div style={{ width: 22, height: 22, borderRadius: '50%', background: '#2a2518', display: 'grid', placeItems: 'center', color: '#f0ead8', fontSize: '0.75rem', fontWeight: 700 }}>
{reply.authorName?.slice(0, 1).toUpperCase() || 'S'}
</div> </div>
)} <p className="community-post-text">{reply.message}</p>
<strong style={{ color: '#c9a84c', fontSize: '0.95rem' }}>{reply.authorName || 'Student'}</strong>
</div> </div>
<span className="study-note-status">{formatCommunityDate(reply.createdAt)}</span>
</div>
<p className="study-detail-copy" style={{ margin: '0.25rem 0 0', whiteSpace: 'pre-wrap' }}>{reply.message}</p>
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
</article>
) )
})} })}
</div> </div>
@@ -1256,7 +1291,7 @@ export function ColossiansStudyIndexPage({ content }: Props) {
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [] }) const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [] })
const [enrolling, setEnrolling] = useState(false) const [enrolling, setEnrolling] = useState(false)
const [enrollMessage, setEnrollMessage] = useState('') const [enrollMessage, setEnrollMessage] = useState('')
const [studyProgress, setStudyProgress] = useState<StudyProgress>({ completedSectionIds: [] }) const [studyProgress, setStudyProgress] = useState<StudyProgress>({ completedSectionIds: [], bookmarkedSectionIds: [] })
const [progressLoading, setProgressLoading] = useState(false) const [progressLoading, setProgressLoading] = useState(false)
useEffect(() => { useEffect(() => {
@@ -1305,14 +1340,14 @@ export function ColossiansStudyIndexPage({ content }: Props) {
let cancelled = false let cancelled = false
setProgressLoading(true) 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 => { .then(data => {
if (cancelled) return 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(() => { .catch(() => {
if (cancelled) return if (cancelled) return
setStudyProgress({ completedSectionIds: [] }) setStudyProgress({ completedSectionIds: [], bookmarkedSectionIds: [] })
}) })
.finally(() => { .finally(() => {
if (cancelled) return if (cancelled) return
@@ -1499,9 +1534,13 @@ export function ColossiansStudySectionPage({ content }: Props) {
const noteMapDirtyRef = useRef(false) const noteMapDirtyRef = useRef(false)
const [contextNoteMenu, setContextNoteMenu] = useState<{ x: number; y: number; text: string; anchor: NoteAnchorId } | null>(null) const [contextNoteMenu, setContextNoteMenu] = useState<{ x: number; y: number; text: string; anchor: NoteAnchorId } | null>(null)
const [completedSectionIds, setCompletedSectionIds] = useState<string[]>([]) const [completedSectionIds, setCompletedSectionIds] = useState<string[]>([])
const [bookmarkedSectionIds, setBookmarkedSectionIds] = useState<string[]>([])
const [bookmarkSaving, setBookmarkSaving] = useState(false)
const [progressSaving, setProgressSaving] = useState(false) const [progressSaving, setProgressSaving] = useState(false)
const [progressMessage, setProgressMessage] = useState('') const [progressMessage, setProgressMessage] = useState('')
const [checkpointAnswers, setCheckpointAnswers] = useState<Record<number, string>>({}) const [checkpointAnswers, setCheckpointAnswers] = useState<Record<number, string>>({})
const [quizAnswers, setQuizAnswers] = useState<string[]>([])
const quizAnswersDirtyRef = useRef(false)
const [showCelebration, setShowCelebration] = useState(false) const [showCelebration, setShowCelebration] = useState(false)
const [bannerDismissed, setBannerDismissed] = useState(() => localStorage.getItem('study-banner-dismissed-v1') === '1') const [bannerDismissed, setBannerDismissed] = useState(() => localStorage.getItem('study-banner-dismissed-v1') === '1')
const [autoSaveMsg, setAutoSaveMsg] = useState('') const [autoSaveMsg, setAutoSaveMsg] = useState('')
@@ -1568,14 +1607,16 @@ export function ColossiansStudySectionPage({ content }: Props) {
let cancelled = false let cancelled = false
setProgressMessage('') 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 => { .then(data => {
if (cancelled) return if (cancelled) return
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : []) setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
setBookmarkedSectionIds(Array.isArray(data.bookmarkedSectionIds) ? data.bookmarkedSectionIds : [])
}) })
.catch(() => { .catch(() => {
if (cancelled) return if (cancelled) return
setCompletedSectionIds([]) setCompletedSectionIds([])
setBookmarkedSectionIds([])
}) })
.finally(() => { .finally(() => {
if (cancelled) return if (cancelled) return
@@ -1685,6 +1726,44 @@ export function ColossiansStudySectionPage({ content }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [noteMap, noteId, auth.authenticated, noteLoading]) }, [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(() => { useEffect(() => {
if (!showCelebration) return if (!showCelebration) return
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
@@ -1889,6 +1968,11 @@ export function ColossiansStudySectionPage({ content }: Props) {
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: `Lesson ${lessonNumber}` }]} /> <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: `Lesson ${lessonNumber}` }]} />
<Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link> <Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link>
<p className="study-lesson-label">Lesson {lessonNumber} of {sections.length}</p> <p className="study-lesson-label">Lesson {lessonNumber} of {sections.length}</p>
{sections.length > 0 && (
<div className="study-lesson-progress-bar" role="progressbar" aria-valuenow={completedSectionIds.length} aria-valuemax={sections.length} aria-label={`${completedSectionIds.length} of ${sections.length} lessons completed`}>
<div className="study-lesson-progress-fill" style={{ width: `${Math.round((completedSectionIds.length / sections.length) * 100)}%` }} />
</div>
)}
<h1>{section.title}</h1> <h1>{section.title}</h1>
<p className="study-detail-reference">{section.reference}</p> <p className="study-detail-reference">{section.reference}</p>
<p className="study-detail-summary">{section.summary}</p> <p className="study-detail-summary">{section.summary}</p>
@@ -1920,9 +2004,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
type="button" type="button"
className={`note-anchor-btn${noteMap['scripture']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'scripture' ? ' note-anchor-btn--open' : ''}`} 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')} onClick={() => setOpenAnchor(curr => curr === 'scripture' ? null : 'scripture')}
title={noteMap['scripture']?.trim() ? 'Edit scripture note' : 'Add note to Scripture'}
aria-pressed={openAnchor === 'scripture'} aria-pressed={openAnchor === 'scripture'}
>✎</button> >{noteMap['scripture']?.trim() ? '✎ Note' : '+ Note'}</button>
)} )}
<h2>Scripture Text</h2> <h2>Scripture Text</h2>
{bsbPassageLoading {bsbPassageLoading
@@ -1936,15 +2019,6 @@ export function ColossiansStudySectionPage({ content }: Props) {
: <p className="study-detail-copy" style={{ opacity: 0.5 }}>Passage text not available.</p> : <p className="study-detail-copy" style={{ opacity: 0.5 }}>Passage text not available.</p>
} }
{bsbPassage && <p style={{ fontSize: '0.78rem', color: '#5a5440', marginTop: '0.5rem' }}>Berean Standard Bible (BSB)</p>} {bsbPassage && <p style={{ fontSize: '0.78rem', color: '#5a5440', marginTop: '0.5rem' }}>Berean Standard Bible (BSB)</p>}
{isEnrolled && openAnchor === 'scripture' && (
<NoteCard
anchorLabel="Scripture Text"
html={noteMap['scripture'] ?? ''}
autoSaveMsg={autoSaveMsg}
onChange={html => updateNote('scripture', html)}
onClose={() => setOpenAnchor(null)}
/>
)}
</article> </article>
<article className="study-class-block study-class-block--anchored" data-note-anchor="commentary"> <article className="study-class-block study-class-block--anchored" data-note-anchor="commentary">
@@ -1953,9 +2027,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
type="button" type="button"
className={`note-anchor-btn${noteMap['commentary']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'commentary' ? ' note-anchor-btn--open' : ''}`} 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')} onClick={() => setOpenAnchor(curr => curr === 'commentary' ? null : 'commentary')}
title={noteMap['commentary']?.trim() ? 'Edit commentary note' : 'Add note to Commentary'}
aria-pressed={openAnchor === 'commentary'} aria-pressed={openAnchor === 'commentary'}
>✎</button> >{noteMap['commentary']?.trim() ? '✎ Note' : '+ Note'}</button>
)} )}
<h2>Instructor Commentary</h2> <h2>Instructor Commentary</h2>
{section.commentary.split(/\n{2,}/).map((para, i) => ( {section.commentary.split(/\n{2,}/).map((para, i) => (
@@ -1965,15 +2038,6 @@ export function ColossiansStudySectionPage({ content }: Props) {
))} ))}
</p> </p>
))} ))}
{isEnrolled && openAnchor === 'commentary' && (
<NoteCard
anchorLabel="Commentary"
html={noteMap['commentary'] ?? ''}
autoSaveMsg={autoSaveMsg}
onChange={html => updateNote('commentary', html)}
onClose={() => setOpenAnchor(null)}
/>
)}
</article> </article>
<article className="study-class-block study-class-block--anchored" data-note-anchor="questions"> <article className="study-class-block study-class-block--anchored" data-note-anchor="questions">
@@ -1982,33 +2046,35 @@ export function ColossiansStudySectionPage({ content }: Props) {
type="button" type="button"
className={`note-anchor-btn${noteMap['questions']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'questions' ? ' note-anchor-btn--open' : ''}`} 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')} onClick={() => setOpenAnchor(curr => curr === 'questions' ? null : 'questions')}
title={noteMap['questions']?.trim() ? 'Edit reflection note' : 'Add note to Reflection Questions'}
aria-pressed={openAnchor === 'questions'} aria-pressed={openAnchor === 'questions'}
>✎</button> >{noteMap['questions']?.trim() ? '✎ Note' : '+ Note'}</button>
)} )}
<h2>Reflection Questions</h2> <h2>Reflection Questions</h2>
<p className="study-detail-copy" style={{ marginBottom: '0.75rem', color: '#8a7f5a', fontSize: '0.9rem' }}> <ol className="study-detail-list study-detail-list--questions">
Work through these on your own, then share your thoughts in the Discussion below.
</p>
<ol className="study-detail-list">
{section.studyQuestions.map((question, index) => ( {section.studyQuestions.map((question, index) => (
<li key={index}>{question}</li> <li key={index} className="study-question-item">
<p className="study-question-text">{question}</p>
{isEnrolled && (
<textarea
className="study-question-answer"
value={quizAnswers[index] ?? ''}
onChange={e => {
const next = [...quizAnswers]
next[index] = e.target.value
setQuizAnswers(next)
quizAnswersDirtyRef.current = true
}}
placeholder="Write your answer…"
rows={2}
/>
)}
</li>
))} ))}
</ol> </ol>
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}> <div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Link to={`/study/${study.slug}/${section.id}/quiz`} className="btn-secondary">Write My Answers</Link>
<Link to={`/study/${study.slug}/community`} className="btn-primary">Go to Community</Link> <Link to={`/study/${study.slug}/community`} className="btn-primary">Go to Community</Link>
<Link to={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-secondary">Ask Nate</Link> <Link to={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-secondary">Ask Nate</Link>
</div> </div>
{isEnrolled && openAnchor === 'questions' && (
<NoteCard
anchorLabel="Reflection Questions"
html={noteMap['questions'] ?? ''}
autoSaveMsg={autoSaveMsg}
onChange={html => updateNote('questions', html)}
onClose={() => setOpenAnchor(null)}
/>
)}
</article> </article>
{isEnrolled && !(section.checkpointQuestions && section.checkpointQuestions.length > 0) && ( {isEnrolled && !(section.checkpointQuestions && section.checkpointQuestions.length > 0) && (
@@ -2017,6 +2083,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
<h2 style={{ margin: 0 }}>Lesson Progress</h2> <h2 style={{ margin: 0 }}>Lesson Progress</h2>
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: '#5a5440' }}>No checkpoint questions for this lesson.</p> <p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: '#5a5440' }}>No checkpoint questions for this lesson.</p>
</div> </div>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<button <button
type="button" type="button"
className={lessonCompleted ? 'btn-secondary' : 'btn-primary'} className={lessonCompleted ? 'btn-secondary' : 'btn-primary'}
@@ -2041,6 +2108,31 @@ export function ColossiansStudySectionPage({ content }: Props) {
> >
{progressSaving ? '…' : lessonCompleted ? '✓ Completed' : 'Mark as Completed'} {progressSaving ? '…' : lessonCompleted ? '✓ Completed' : 'Mark as Completed'}
</button> </button>
{isEnrolled && section?.id && (
<button
type="button"
className="btn-secondary"
style={bookmarkedSectionIds.includes(section.id) ? { borderColor: '#c9a84c', color: '#c9a84c' } : {}}
disabled={bookmarkSaving}
title={bookmarkedSectionIds.includes(section.id) ? 'Remove bookmark' : 'Bookmark this lesson'}
onClick={async () => {
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'}
</button>
)}
</div>
</article> </article>
)} )}
@@ -2100,28 +2192,40 @@ export function ColossiansStudySectionPage({ content }: Props) {
</div> </div>
<aside className="study-classroom-sidebar" aria-label="Lesson tools"> <aside className="study-classroom-sidebar" aria-label="Lesson tools">
{/* Lesson navigator */}
<article className="study-class-side-block"> <article className="study-class-side-block">
<h3>Study Track</h3> <h3>Lessons <span className="study-lesson-nav-progress">{completedSectionIds.length}/{sections.length}</span></h3>
<p>{study.title}</p> <nav className="study-lesson-nav" aria-label="All lessons">
<p>{study.description}</p> {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 (
<Link
key={s.id}
to={released ? `/study/${study.slug}/${s.id}` : '#'}
aria-current={isCurr ? 'page' : undefined}
className={`study-lesson-nav-item${isCurr ? ' study-lesson-nav-item--current' : ''}${isDone ? ' study-lesson-nav-item--done' : ''}${!released ? ' study-lesson-nav-item--locked' : ''}`}
onClick={e => { if (!released) e.preventDefault() }}
>
<span className="study-lesson-nav-icon" aria-hidden="true">
{isDone ? '✓' : isCurr ? String(sNum) : released ? String(sNum) : '○'}
</span>
<span className="study-lesson-nav-label">{s.title}</span>
</Link>
)
})}
</nav>
</article> </article>
{/* Notes panel */}
<article className="study-class-side-block"> <article className="study-class-side-block">
<h3>Greek Word Study</h3> <h3>
{section.greekNotes.length > 0 ? ( Your Notes
<ul className="study-detail-list"> {savedNoteCount > 0 && <span className="study-notes-count-badge">{savedNoteCount}</span>}
{section.greekNotes.map((note, index) => ( </h3>
<li key={index}>{note}</li> {!auth.checked && <p className="study-detail-copy" style={{ fontSize: '0.85rem' }}>Checking sign-in…</p>}
))}
</ul>
) : (
<p className="study-detail-copy">Greek notes will be added for this lesson.</p>
)}
</article>
<article className="study-class-side-block">
<h3>Student Notes</h3>
{!auth.checked && <p className="study-detail-copy">Checking sign-in status...</p>}
{auth.checked && !auth.authenticated && ( {auth.checked && !auth.authenticated && (
<div className="study-auth-box"> <div className="study-auth-box">
<p className="study-auth-why">Sign in to save notes for this lesson.</p> <p className="study-auth-why">Sign in to save notes for this lesson.</p>
@@ -2133,60 +2237,71 @@ export function ColossiansStudySectionPage({ content }: Props) {
<button type="button" className="btn-secondary" disabled={authBusy} onClick={() => submitAuth('login')}>Sign In</button> <button type="button" className="btn-secondary" disabled={authBusy} onClick={() => submitAuth('login')}>Sign In</button>
<button type="button" className="btn-primary" disabled={authBusy} onClick={() => submitAuth('signup')}>Create Account</button> <button type="button" className="btn-primary" disabled={authBusy} onClick={() => submitAuth('signup')}>Create Account</button>
</div> </div>
{authMessage && <p className="study-note-status" style={{ marginTop: '0.5rem' }}>{authMessage}</p>}
</div> </div>
)} )}
{auth.checked && auth.authenticated && ( {auth.checked && auth.authenticated && (
<div className="study-notes-box">
<p className="study-notes-user">Signed in as {auth.username}</p>
<Link to="/study/account" className="btn-secondary" style={{ marginBottom: '0.5rem', display: 'inline-block' }}>My Account</Link>
{noteLoading ? (
<p style={{ color: '#7a7060', fontSize: '0.85rem', marginTop: '0.5rem' }}>Loading notes…</p>
) : (
<> <>
{savedNoteCount > 0 && ( {noteLoading ? (
<p style={{ fontSize: '0.82rem', color: '#7a9c6a', marginBottom: '0.5rem' }}> <p style={{ color: '#7a7060', fontSize: '0.85rem' }}>Loading notes…</p>
{savedNoteCount} note{savedNoteCount !== 1 ? 's' : ''} saved ✓ ) : openAnchor ? (
<NoteCard
anchorLabel={ANCHOR_LABELS[openAnchor] ?? 'Notes'}
html={noteMap[openAnchor] ?? ''}
autoSaveMsg={autoSaveMsg}
onChange={html => updateNote(openAnchor as NoteAnchorId, html)}
onClose={() => setOpenAnchor(null)}
/>
) : (
<p style={{ fontSize: '0.85rem', color: '#5a5040', lineHeight: 1.55 }}>
{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.'}
</p> </p>
)} )}
<div className="note-anchor-list"> {autoSaveMsg && <p style={{ fontSize: '0.82rem', color: '#7a9c6a', marginTop: '0.35rem' }}>{autoSaveMsg}</p>}
{NOTE_ANCHORS.map(({ id, label }) => { {noteMessage && <p className="study-note-status" style={{ marginTop: '0.35rem' }}>{noteMessage}</p>}
const hasNote = !!(noteMap[id]?.trim()) <Link to="/study/account" style={{ display: 'block', fontSize: '0.82rem', color: '#7a7060', marginTop: '0.75rem' }}>My Account →</Link>
return (
<button
key={id}
type="button"
className={`note-anchor-list-item${hasNote ? ' note-anchor-list-item--has' : ''}${openAnchor === id ? ' note-anchor-list-item--open' : ''}`}
onClick={() => setOpenAnchor(curr => curr === id ? null : id as NoteAnchorId)}
>
<span>{label}</span>
{hasNote && <span className="note-anchor-dot" aria-hidden="true">●</span>}
</button>
)
})}
</div>
<p style={{ margin: '0.5rem 0 0', color: '#7a7060', fontSize: '0.82rem' }}>Click a section above to add notes. Right-click lesson text to append a selection.</p>
</> </>
)} )}
</div>
)}
{(authMessage || noteMessage || progressMessage) && <p className="study-note-status">{authMessage || noteMessage || progressMessage}</p>}
</article> </article>
<article className="study-class-side-block"> {/* Community shortcut */}
<h3>Lesson Navigation</h3> {isEnrolled && (
<div className="study-detail-nav"> <article className="study-class-side-block study-class-side-block--community">
{previousSection ? ( <Link to={`/study/${study.slug}/community?sectionId=${section.id}`} className="study-community-shortcut">
<Link to={`/study/${study.slug}/${previousSection.id}`} className="btn-secondary">Previous Lesson</Link> <svg viewBox="0 0 24 24" aria-hidden="true" className="study-community-shortcut-icon"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" /></svg>
) : <span className="study-nav-placeholder" />} <span>Discuss this lesson</span>
{nextSection && !isSectionReleased(nextSection) ? ( <span className="study-community-shortcut-arrow">→</span>
<span className="study-nav-placeholder">Available {getReleaseDateDisplay(nextSection)}</span> </Link>
) : nextSection && !lessonCompleted ? (
<span className="study-nav-placeholder" title="Complete this lesson's checkpoint to unlock the next one">🔒 Complete checkpoint first</span>
) : nextSection ? (
<Link to={`/study/${study.slug}/${nextSection.id}`} className="btn-secondary">Next Lesson</Link>
) : <span className="study-nav-placeholder" />}
</div>
</article> </article>
)}
{/* Greek Word Study */}
{section.greekNotes.length > 0 && (
<article className="study-class-side-block">
<h3>Greek Word Study</h3>
<ul className="study-detail-list">
{section.greekNotes.map((note, index) => (
<li key={index}>{note}</li>
))}
</ul>
</article>
)}
{/* Prev / Next navigation */}
<div className="study-lesson-nav-actions">
{previousSection ? (
<Link to={`/study/${study.slug}/${previousSection.id}`} className="btn-secondary">← Previous</Link>
) : <span />}
{nextSection && !isSectionReleased(nextSection) ? (
<span className="study-nav-placeholder" style={{ fontSize: '0.82rem', color: '#5a5440' }}>Next available {getReleaseDateDisplay(nextSection)}</span>
) : nextSection && !lessonCompleted ? (
<span className="study-nav-placeholder" style={{ fontSize: '0.82rem', color: '#5a5440' }}>Complete lesson to continue</span>
) : nextSection ? (
<Link to={`/study/${study.slug}/${nextSection.id}`} className="btn-secondary">Next →</Link>
) : null}
</div>
</aside> </aside>
{contextNoteMenu && ( {contextNoteMenu && (
@@ -2439,6 +2554,8 @@ export function StudyAccountPage() {
const [subscribeNewsletter, setSubscribeNewsletter] = useState(true) const [subscribeNewsletter, setSubscribeNewsletter] = useState(true)
const [studyRemindersEnabled, setStudyRemindersEnabled] = useState(true) const [studyRemindersEnabled, setStudyRemindersEnabled] = useState(true)
const [reminderFrequency, setReminderFrequency] = useState<'immediate' | 'weekly'>('immediate')
const [remindersPausedUntil, setRemindersPausedUntil] = useState<string | null>(null)
const [prefMessage, setPrefMessage] = useState('') const [prefMessage, setPrefMessage] = useState('')
const [prefBusy, setPrefBusy] = useState(false) const [prefBusy, setPrefBusy] = useState(false)
const [accountModal, setAccountModal] = useState<'none' | 'enrollments' | 'changeEmail' | 'changePassword' | 'profile' | 'preferences' | 'export' | 'choose2fa' | 'setup2fa' | 'setup2fa-email' | 'disable2fa' | 'recoveryCodes'>('none') const [accountModal, setAccountModal] = useState<'none' | 'enrollments' | 'changeEmail' | 'changePassword' | 'profile' | 'preferences' | 'export' | 'choose2fa' | 'setup2fa' | 'setup2fa-email' | 'disable2fa' | 'recoveryCodes'>('none')
@@ -2509,6 +2626,8 @@ export function StudyAccountPage() {
setAvatarUrlInput(data.profile.avatarUrl ?? '') setAvatarUrlInput(data.profile.avatarUrl ?? '')
setSubscribeNewsletter(data.profile.subscribeNewsletter !== false) setSubscribeNewsletter(data.profile.subscribeNewsletter !== false)
setStudyRemindersEnabled(data.profile.studyRemindersEnabled === true) setStudyRemindersEnabled(data.profile.studyRemindersEnabled === true)
setReminderFrequency(data.profile.reminderFrequency === 'weekly' ? 'weekly' : 'immediate')
setRemindersPausedUntil(data.profile.remindersPausedUntil ?? null)
}) })
.catch(() => {}) .catch(() => {})
.finally(() => setLoadingOverview(false)) .finally(() => setLoadingOverview(false))
@@ -2541,6 +2660,8 @@ export function StudyAccountPage() {
setAvatarUrlInput(data.profile.avatarUrl ?? '') setAvatarUrlInput(data.profile.avatarUrl ?? '')
setSubscribeNewsletter(data.profile.subscribeNewsletter !== false) setSubscribeNewsletter(data.profile.subscribeNewsletter !== false)
setStudyRemindersEnabled(data.profile.studyRemindersEnabled === true) setStudyRemindersEnabled(data.profile.studyRemindersEnabled === true)
setReminderFrequency(data.profile.reminderFrequency === 'weekly' ? 'weekly' : 'immediate')
setRemindersPausedUntil(data.profile.remindersPausedUntil ?? null)
} }
async function handleAvatarUpload(file: File) { async function handleAvatarUpload(file: File) {
@@ -2600,12 +2721,14 @@ export function StudyAccountPage() {
setPrefBusy(true) setPrefBusy(true)
setPrefMessage('') setPrefMessage('')
try { 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', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, 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 })) setAuth(prev => ({ ...prev, subscribeNewsletter: data.subscribeNewsletter, studyRemindersEnabled: data.studyRemindersEnabled }))
setReminderFrequency(data.reminderFrequency === 'weekly' ? 'weekly' : 'immediate')
setRemindersPausedUntil(data.remindersPausedUntil ?? null)
setPrefMessage('Preferences updated.') setPrefMessage('Preferences updated.')
await refreshOverview() await refreshOverview()
} catch (err) { } catch (err) {
@@ -3008,6 +3131,99 @@ export function StudyAccountPage() {
<input type="checkbox" checked={studyRemindersEnabled} onChange={e => setStudyRemindersEnabled(e.target.checked)} /> <input type="checkbox" checked={studyRemindersEnabled} onChange={e => setStudyRemindersEnabled(e.target.checked)} />
<span>Send email reminders when a new lesson is released</span> <span>Send email reminders when a new lesson is released</span>
</label> </label>
{studyRemindersEnabled && (
<div style={{ marginLeft: '2rem', display: 'grid', gap: '0.75rem' }}>
<div>
<p style={{ margin: '0 0 0.4rem', fontSize: '0.875rem', color: '#b9b09b' }}>Reminder frequency</p>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={() => 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
</button>
</div>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.8rem', color: '#7a7060' }}>
{reminderFrequency === 'weekly'
? 'Receive one weekly email summarizing all new lessons.'
: 'Receive an email each time a new lesson is released.'}
</p>
</div>
<div>
<p style={{ margin: '0 0 0.4rem', fontSize: '0.875rem', color: '#b9b09b' }}>
Pause reminders
{remindersPausedUntil && new Date(remindersPausedUntil) > new Date()
? ` · paused until ${new Date(remindersPausedUntil).toLocaleDateString()}`
: ''}
</p>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{[7, 14, 30].map(days => (
<button
key={days}
type="button"
onClick={() => {
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
</button>
))}
{remindersPausedUntil && new Date(remindersPausedUntil) > new Date() && (
<button
type="button"
onClick={() => 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
</button>
)}
</div>
</div>
</div>
)}
<label className="contact-consent" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}> <label className="contact-consent" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input type="checkbox" checked={subscribeNewsletter} onChange={e => setSubscribeNewsletter(e.target.checked)} /> <input type="checkbox" checked={subscribeNewsletter} onChange={e => setSubscribeNewsletter(e.target.checked)} />
<span>Subscribe to newsletter updates</span> <span>Subscribe to newsletter updates</span>
+23
View File
@@ -67,6 +67,13 @@ export interface PodcastFeaturedLink {
summary: string summary: string
url: string url: string
embedUrl?: 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 showNotes?: string
discussionQuestions?: string[] discussionQuestions?: string[]
} }
@@ -252,6 +259,15 @@ export interface SiteContent {
studyReengagementT3Subject: string studyReengagementT3Subject: string
studyReengagementT3Body: string studyReengagementT3Body: string
studyReengagementT3Cta: 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 ── // ── Email Change Confirmation ──
emailChangeSubject: string emailChangeSubject: string
emailChangeBody: string emailChangeBody: string
@@ -465,6 +481,13 @@ export const DEFAULTS: SiteContent = {
studyReengagementT3Subject: 'Your progress is still here', 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.", studyReengagementT3Body: "Your study progress is still intact and waiting. There's no deadline — come back whenever you're ready.",
studyReengagementT3Cta: 'Open Your Study', 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', 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.', 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', emailChangeCtaLabel: 'Confirm Email Change',