Merge feature/show-notes-form into main — v2.0.0
Publish Container / docker (push) Successful in 13m47s
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:
commit
274adcc093
14 files changed
+1258
-284
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "1.2.2",
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
refreshContentCaches,
|
||||
queueHitStatsWrite,
|
||||
} from './server/data.js'
|
||||
import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail } from './server/email.js'
|
||||
import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail, sendStudyWeeklyDigestEmail } from './server/email.js'
|
||||
import {
|
||||
detectBot,
|
||||
sanitizeUserAgent,
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
recordHit,
|
||||
scheduleStudyReminders,
|
||||
scheduleReengagementEmails,
|
||||
scheduleWeeklyDigestEmails,
|
||||
} from './server/study-helpers.js'
|
||||
import { recordVisitor } from './server/routes/analytics.js'
|
||||
|
||||
@@ -194,6 +195,14 @@ Promise.all([
|
||||
}, 60 * 60 * 1000)
|
||||
void scheduleReengagementEmails(sendStudyReengagementEmail)
|
||||
|
||||
// Send weekly lesson digest emails every hour (only actually fires every 7 days per user)
|
||||
setInterval(() => {
|
||||
scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail).catch(err => {
|
||||
console.error('[study-digest] failed to schedule weekly digest:', err)
|
||||
})
|
||||
}, 60 * 60 * 1000)
|
||||
void scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail)
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logResendEmailAlignmentWarnings()
|
||||
console.log(`Siteforge listening on http://localhost:${PORT}`)
|
||||
|
||||
@@ -1415,6 +1415,9 @@ export function sanitizeStudyUsers(value) {
|
||||
totpEnabledAt: typeof item?.totpEnabledAt === 'string' ? item.totpEnabledAt : null,
|
||||
totpRecoveryCodes: Array.isArray(item?.totpRecoveryCodes) ? item.totpRecoveryCodes.filter(h => typeof h === 'string') : [],
|
||||
totpSecretPending: typeof item?.totpSecretPending === 'string' ? item.totpSecretPending : undefined,
|
||||
remindersPausedUntil: typeof item?.remindersPausedUntil === 'string' ? item.remindersPausedUntil : null,
|
||||
reminderFrequency: item?.reminderFrequency === 'weekly' ? 'weekly' : 'immediate',
|
||||
weeklyDigestSentAt: typeof item?.weeklyDigestSentAt === 'string' ? item.weeklyDigestSentAt : null,
|
||||
})
|
||||
}
|
||||
return out.slice(0, MAX_STUDY_USERS)
|
||||
@@ -1459,8 +1462,12 @@ export function sanitizeStudyProgress(value) {
|
||||
])
|
||||
)
|
||||
: {}
|
||||
const bookmarkedSectionIds = Array.isArray(studyData?.bookmarkedSectionIds)
|
||||
? Array.from(new Set(studyData.bookmarkedSectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim())))
|
||||
: []
|
||||
progress.byStudy[studySlug.trim().toLowerCase()] = {
|
||||
completedSectionIds: Array.from(new Set(completedSectionIds)),
|
||||
bookmarkedSectionIds,
|
||||
quizAnswers,
|
||||
}
|
||||
}
|
||||
@@ -1516,6 +1523,7 @@ export function sanitizeStudyCommunityPosts(value) {
|
||||
? item.replies.map(sanitizeStudyCommunityReply).filter(Boolean).slice(0, 50)
|
||||
: []
|
||||
if (!studySlug || !message) return null
|
||||
const likedBy = Array.isArray(item.likedBy) ? item.likedBy.filter(id => typeof id === 'string' && id.trim()).slice(0, 1000) : []
|
||||
return {
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
studySlug,
|
||||
@@ -1525,6 +1533,7 @@ export function sanitizeStudyCommunityPosts(value) {
|
||||
message,
|
||||
createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
|
||||
replies,
|
||||
likedBy,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -635,3 +635,80 @@ export async function sendStudyReengagementEmail(email, displayName, studyTitle,
|
||||
console.error('[study-reengagement] send exception:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendStudyCertificateEmail(email, displayName, studyTitle, certUrl) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const cfg = state.cachedSiteContent ?? {}
|
||||
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
||||
const subject = cfg.studyCertificateEmailSubject?.trim() || 'Your certificate is ready'
|
||||
const bodyText = cfg.studyCertificateEmailBody?.trim() || 'Congratulations — you have completed every lesson in this study. Your certificate is ready to view and share.'
|
||||
const ctaLabel = cfg.studyCertificateEmailCtaLabel?.trim() || 'View Certificate'
|
||||
const signoff = cfg.studyCertificateEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate'
|
||||
const bodyHtml = (
|
||||
`<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)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
readUploadsMetadata,
|
||||
writeUploadsMetadata,
|
||||
getUserNotesFilePath,
|
||||
loadUserProgress,
|
||||
queueStudyUsersWrite,
|
||||
} from '../data.js'
|
||||
import {
|
||||
@@ -16,7 +17,9 @@ import {
|
||||
getStudyCatalog,
|
||||
normalizeStudySlug,
|
||||
isEnrollableStudySlug,
|
||||
makeUnsubUrl,
|
||||
} from '../study-helpers.js'
|
||||
import { sendStudyReengagementEmail } from '../email.js'
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => {
|
||||
@@ -117,9 +120,19 @@ export function register(app) {
|
||||
noteCount = Object.values(notes).filter(n => typeof n === 'string' && n.trim()).length
|
||||
} catch { /* ignore */ }
|
||||
|
||||
let progressByStudy = {}
|
||||
try {
|
||||
const prog = await loadUserProgress(user.id)
|
||||
for (const [slug, data] of Object.entries(prog.byStudy)) {
|
||||
progressByStudy[slug] = { completedLessons: (data.completedSectionIds ?? []).length }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const enrolledStudies = (user.enrolledStudySlugs ?? []).map(slug => {
|
||||
const study = catalog.find(s => s.slug === slug)
|
||||
return study ? { slug, title: study.title } : { slug, title: slug }
|
||||
return study
|
||||
? { slug, title: study.title, completedLessons: progressByStudy[slug]?.completedLessons ?? 0 }
|
||||
: { slug, title: slug, completedLessons: 0 }
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -132,12 +145,50 @@ export function register(app) {
|
||||
noteCount,
|
||||
subscribeNewsletter: user.subscribeNewsletter !== false,
|
||||
studyRemindersEnabled: user.studyRemindersEnabled === true,
|
||||
reminderFrequency: user.reminderFrequency ?? 'immediate',
|
||||
remindersPausedUntil: user.remindersPausedUntil ?? null,
|
||||
currentStreak: user.currentStreak ?? 0,
|
||||
longestStreak: user.longestStreak ?? 0,
|
||||
reengagementSentAt: user.reengagementSentAt ?? {},
|
||||
}
|
||||
}))
|
||||
|
||||
res.json({ users })
|
||||
})
|
||||
|
||||
app.post('/api/admin/study-users/:id/send-reengagement', requireAdminAuth, async (req, res) => {
|
||||
const user = state.studyUsers.find(u => u.id === req.params.id)
|
||||
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
|
||||
|
||||
const tier = req.body?.tier
|
||||
if (tier !== 't1' && tier !== 't2' && tier !== 't3') {
|
||||
res.status(400).json({ message: 'tier must be t1, t2, or t3.' }); return
|
||||
}
|
||||
|
||||
const studies = Array.isArray(state.cachedSiteContent?.studies) ? state.cachedSiteContent.studies : []
|
||||
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||
const enrolledStudies = enrolledStudySlugs
|
||||
.map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug))
|
||||
.filter(Boolean)
|
||||
|
||||
if (enrolledStudies.length === 0) { res.status(400).json({ message: 'User has no enrolled studies.' }); return }
|
||||
|
||||
const firstStudy = enrolledStudies[0]
|
||||
const studyTitle = enrolledStudies.length === 1 ? firstStudy.title : null
|
||||
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
|
||||
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
||||
const studyUrl = enrolledStudies.length === 1 ? `${base}/study/${firstStudy.slug}` : `${base}/study`
|
||||
const unsubUrl = makeUnsubUrl(user.id)
|
||||
|
||||
try {
|
||||
await sendStudyReengagementEmail(user.username, user.displayName || user.username, studyTitle, studyUrl, tier, unsubUrl)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error(`[admin] manual reengagement send failed for ${user.username}:`, err)
|
||||
res.status(500).json({ message: 'Failed to send email.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.patch('/api/admin/study-users/:id', requireAdminAuth, (req, res) => {
|
||||
const user = state.studyUsers.find(u => u.id === req.params.id)
|
||||
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
|
||||
|
||||
@@ -218,6 +218,8 @@ export function register(app) {
|
||||
displayName: user.displayName ?? '',
|
||||
subscribeNewsletter: user.subscribeNewsletter !== false,
|
||||
studyRemindersEnabled: user.studyRemindersEnabled === true,
|
||||
reminderFrequency: user.reminderFrequency ?? 'immediate',
|
||||
remindersPausedUntil: user.remindersPausedUntil ?? null,
|
||||
avatarUrl: getStudyAvatarUrl(user),
|
||||
},
|
||||
stats: {
|
||||
@@ -303,6 +305,20 @@ export function register(app) {
|
||||
const studyRemindersEnabled = req.body?.studyRemindersEnabled === true
|
||||
user.subscribeNewsletter = subscribeNewsletter
|
||||
user.studyRemindersEnabled = studyRemindersEnabled
|
||||
|
||||
const reminderFrequency = req.body?.reminderFrequency
|
||||
if (reminderFrequency === 'weekly' || reminderFrequency === 'immediate') {
|
||||
user.reminderFrequency = reminderFrequency
|
||||
}
|
||||
|
||||
const rawPaused = req.body?.remindersPausedUntil
|
||||
if (rawPaused === null || rawPaused === '') {
|
||||
user.remindersPausedUntil = null
|
||||
} else if (typeof rawPaused === 'string') {
|
||||
const parsed = new Date(rawPaused)
|
||||
user.remindersPausedUntil = !isNaN(parsed.getTime()) && parsed.getTime() > Date.now() ? parsed.toISOString() : null
|
||||
}
|
||||
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
|
||||
@@ -310,7 +326,13 @@ export function register(app) {
|
||||
syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err))
|
||||
}
|
||||
|
||||
res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter, studyRemindersEnabled: user.studyRemindersEnabled === true })
|
||||
res.json({
|
||||
ok: true,
|
||||
subscribeNewsletter: user.subscribeNewsletter,
|
||||
studyRemindersEnabled: user.studyRemindersEnabled === true,
|
||||
reminderFrequency: user.reminderFrequency ?? 'immediate',
|
||||
remindersPausedUntil: user.remindersPausedUntil ?? null,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { requireAdminAuth } from '../auth.js'
|
||||
import { requireStudyAuth, normalizeStudySlug, isStudyUserEnrolled, getStudyTitleBySlug } from '../study-helpers.js'
|
||||
import { state } from '../state.js'
|
||||
import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js'
|
||||
import { sendStudyCertificateEmail, getCanonicalBaseUrl } from '../email.js'
|
||||
import { buildAbsoluteUrl } from '../helpers.js'
|
||||
|
||||
const publicCertRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
@@ -95,6 +97,9 @@ export function register(app) {
|
||||
}
|
||||
state.studyCertificates.push(cert)
|
||||
queueStudyCertificatesWrite()
|
||||
const certUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/certificate/${cert.token}`)
|
||||
sendStudyCertificateEmail(user.username, user.displayName || user.username, cert.studyTitle, certUrl)
|
||||
.catch(err => console.error('[study-certificate] email exception:', err))
|
||||
}
|
||||
|
||||
res.json({ ok: true, token: cert.token, issuedAt: cert.issuedAt })
|
||||
|
||||
@@ -171,6 +171,7 @@ export function register(app) {
|
||||
|
||||
const progress = await loadUserProgress(user.id)
|
||||
const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
|
||||
const bookmarkedSectionIds = progress.byStudy[studySlug]?.bookmarkedSectionIds ?? []
|
||||
|
||||
// Record first visit timestamp on the user record (additive, never overwrite)
|
||||
if (!user.firstVisitAt) {
|
||||
@@ -178,7 +179,7 @@ export function register(app) {
|
||||
queueStudyUsersWrite()
|
||||
}
|
||||
|
||||
res.json({ studySlug, completedSectionIds })
|
||||
res.json({ studySlug, completedSectionIds, bookmarkedSectionIds })
|
||||
})
|
||||
|
||||
app.post('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
|
||||
@@ -301,6 +302,45 @@ export function register(app) {
|
||||
res.json({ ok: true, studySlug, sectionId, answers })
|
||||
})
|
||||
|
||||
// ── Bookmarks ─────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/api/study-progress/:studySlug/:sectionId/bookmark', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||
if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return }
|
||||
if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to bookmark lessons.' }); return }
|
||||
|
||||
const progress = await loadUserProgress(user.id)
|
||||
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] }
|
||||
const bookmarkedSectionIds = Array.isArray(studyProgress.bookmarkedSectionIds) ? studyProgress.bookmarkedSectionIds : []
|
||||
if (!bookmarkedSectionIds.includes(sectionId)) {
|
||||
studyProgress.bookmarkedSectionIds = [...bookmarkedSectionIds, sectionId]
|
||||
}
|
||||
progress.byStudy[studySlug] = studyProgress
|
||||
progress.updatedAt = new Date().toISOString()
|
||||
state.studyProgressCache.set(user.id, progress)
|
||||
queueUserProgressWrite(user.id)
|
||||
res.json({ ok: true, studySlug, bookmarkedSectionIds: studyProgress.bookmarkedSectionIds })
|
||||
})
|
||||
|
||||
app.delete('/api/study-progress/:studySlug/:sectionId/bookmark', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||
if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return }
|
||||
if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Not enrolled.' }); return }
|
||||
|
||||
const progress = await loadUserProgress(user.id)
|
||||
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] }
|
||||
studyProgress.bookmarkedSectionIds = (studyProgress.bookmarkedSectionIds ?? []).filter(id => id !== sectionId)
|
||||
progress.byStudy[studySlug] = studyProgress
|
||||
progress.updatedAt = new Date().toISOString()
|
||||
state.studyProgressCache.set(user.id, progress)
|
||||
queueUserProgressWrite(user.id)
|
||||
res.json({ ok: true, studySlug, bookmarkedSectionIds: studyProgress.bookmarkedSectionIds })
|
||||
})
|
||||
|
||||
// ── Community ─────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/study-community', requireStudyAuth, async (req, res) => {
|
||||
@@ -323,9 +363,12 @@ export function register(app) {
|
||||
.slice(0, 50)
|
||||
.map(post => {
|
||||
const author = findStudyUserById(post.authorUserId)
|
||||
const likedBy = Array.isArray(post.likedBy) ? post.likedBy : []
|
||||
return {
|
||||
...post,
|
||||
authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''),
|
||||
likedByMe: likedBy.includes(user.id),
|
||||
likeCount: likedBy.length,
|
||||
replies: Array.isArray(post.replies)
|
||||
? post.replies.map(reply => {
|
||||
const replyAuthor = findStudyUserById(reply.authorUserId)
|
||||
@@ -412,4 +455,23 @@ export function register(app) {
|
||||
|
||||
res.json({ ok: true, reply })
|
||||
})
|
||||
|
||||
app.post('/api/study-community/posts/:postId/like', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : ''
|
||||
const post = state.studyCommunityPosts.find(item => item.id === postId)
|
||||
if (!post) { res.status(404).json({ message: 'Post not found.' }); return }
|
||||
if (!isStudyUserEnrolled(user, post.studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to like posts.' }); return
|
||||
}
|
||||
if (!Array.isArray(post.likedBy)) post.likedBy = []
|
||||
const idx = post.likedBy.indexOf(user.id)
|
||||
if (idx === -1) {
|
||||
post.likedBy.push(user.id)
|
||||
} else {
|
||||
post.likedBy.splice(idx, 1)
|
||||
}
|
||||
queueStudyCommunityWrite()
|
||||
res.json({ ok: true, likeCount: post.likedBy.length, likedByMe: post.likedBy.includes(user.id) })
|
||||
})
|
||||
}
|
||||
+80
-1
@@ -622,6 +622,9 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) {
|
||||
|
||||
for (const user of state.studyUsers) {
|
||||
if (user.studyRemindersEnabled !== true) continue
|
||||
if (user.reminderFrequency === 'weekly') continue
|
||||
const pausedUntil = user.remindersPausedUntil ? new Date(user.remindersPausedUntil) : null
|
||||
if (pausedUntil && !isNaN(pausedUntil.getTime()) && pausedUntil > now) continue
|
||||
const email = user.username
|
||||
const displayName = user.displayName || email
|
||||
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||
@@ -677,7 +680,7 @@ function getReengagementTiers() {
|
||||
return days.slice(0, 3).map((d, i) => ({ key: `t${i + 1}`, days: d }))
|
||||
}
|
||||
|
||||
function makeUnsubUrl(userId) {
|
||||
export function makeUnsubUrl(userId) {
|
||||
const secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge'
|
||||
const token = createHmac('sha256', secret).update(userId).digest('hex')
|
||||
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
|
||||
@@ -694,6 +697,8 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
|
||||
for (const user of state.studyUsers) {
|
||||
if (user.studyRemindersEnabled !== true) continue
|
||||
if (!user.lastLoginAt) continue
|
||||
const pausedUntil = user.remindersPausedUntil ? new Date(user.remindersPausedUntil) : null
|
||||
if (pausedUntil && !isNaN(pausedUntil.getTime()) && pausedUntil > now) continue
|
||||
const lastLogin = new Date(user.lastLoginAt)
|
||||
if (isNaN(lastLogin.getTime())) continue
|
||||
const daysSinceLogin = (nowMs - lastLogin.getTime()) / (1000 * 60 * 60 * 24)
|
||||
@@ -733,3 +738,77 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weekly digest scheduler ───────────────────────────────────────────────
|
||||
|
||||
export async function scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail) {
|
||||
if (!state.cachedSiteContent) return
|
||||
const now = new Date()
|
||||
const nowMs = now.getTime()
|
||||
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
for (const user of state.studyUsers) {
|
||||
if (user.reminderFrequency !== 'weekly') continue
|
||||
if (user.studyRemindersEnabled !== true) continue
|
||||
|
||||
const pausedUntil = user.remindersPausedUntil ? new Date(user.remindersPausedUntil) : null
|
||||
if (pausedUntil && !isNaN(pausedUntil.getTime()) && pausedUntil > now) continue
|
||||
|
||||
const lastDigestMs = user.weeklyDigestSentAt ? new Date(user.weeklyDigestSentAt).getTime() : 0
|
||||
if (nowMs - lastDigestMs < ONE_WEEK_MS) continue
|
||||
|
||||
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||
if (enrolledStudySlugs.length === 0) continue
|
||||
|
||||
const userSent = state.studyReminders.users[user.id] ?? {}
|
||||
const lessons = []
|
||||
const newlySentByStudy = {}
|
||||
|
||||
for (const studySlug of enrolledStudySlugs) {
|
||||
const study = Array.isArray(state.cachedSiteContent.studies)
|
||||
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
|
||||
: undefined
|
||||
if (!study) continue
|
||||
|
||||
for (const section of study.sections ?? []) {
|
||||
const releaseTime = getSectionReleaseTime(section)
|
||||
if (!Number.isFinite(releaseTime) || releaseTime > nowMs) continue
|
||||
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
|
||||
if (sentForStudy.includes(section.id)) continue
|
||||
|
||||
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
|
||||
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
||||
lessons.push({
|
||||
sectionId: section.id,
|
||||
studySlug,
|
||||
sectionTitle: section.title ?? section.id,
|
||||
sectionReference: section.reference ?? '',
|
||||
studyTitle: study.title ?? studySlug,
|
||||
sectionUrl: `${base}/study/${study.slug}/${section.id}`,
|
||||
})
|
||||
if (!newlySentByStudy[studySlug]) newlySentByStudy[studySlug] = []
|
||||
newlySentByStudy[studySlug].push(section.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (lessons.length === 0) continue
|
||||
|
||||
const unsubUrl = makeUnsubUrl(user.id)
|
||||
try {
|
||||
await sendStudyWeeklyDigestEmail(user.username, user.displayName || user.username, lessons, unsubUrl)
|
||||
} catch (err) {
|
||||
console.error(`[study-digest] failed to send digest to ${user.username}:`, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for (const [studySlug, sectionIds] of Object.entries(newlySentByStudy)) {
|
||||
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
|
||||
userSent[studySlug] = [...new Set([...sentForStudy, ...sectionIds])]
|
||||
}
|
||||
state.studyReminders.users[user.id] = userSent
|
||||
queueStudyRemindersWrite()
|
||||
|
||||
user.weeklyDigestSentAt = now.toISOString()
|
||||
queueStudyUsersWrite()
|
||||
}
|
||||
}
|
||||
+126
-4
@@ -78,6 +78,9 @@ interface StudyUserRecord {
|
||||
noteCount: number
|
||||
subscribeNewsletter: boolean
|
||||
studyRemindersEnabled: boolean
|
||||
reminderFrequency: 'immediate' | 'weekly'
|
||||
remindersPausedUntil: string | null
|
||||
reengagementSentAt: Record<string, string>
|
||||
}
|
||||
|
||||
function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
||||
@@ -89,6 +92,7 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
||||
const [newPassword, setNewPassword] = useState<Record<string, string>>({})
|
||||
const [msg, setMsg] = useState<Record<string, string>>({})
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
||||
const [reengagementTier, setReengagementTier] = useState<Record<string, string>>({})
|
||||
|
||||
function flashMsg(id: string, text: string) {
|
||||
setMsg(prev => ({ ...prev, [id]: text }))
|
||||
@@ -154,6 +158,20 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
||||
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
|
||||
}
|
||||
|
||||
async function handleSendReengagement(user: StudyUserRecord) {
|
||||
const tier = reengagementTier[user.id] ?? 't1'
|
||||
try {
|
||||
const res = await fetch(`/api/admin/study-users/${user.id}/send-reengagement`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tier }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.message ?? 'Error')
|
||||
flashMsg(user.id, `Tier ${tier.slice(1)} re-engagement email sent.`)
|
||||
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await fetch(`/api/admin/study-users/${id}`, { method: 'DELETE' })
|
||||
@@ -222,7 +240,13 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
||||
<span>Last studied: {user.lastStudiedDate ?? '—'}</span>
|
||||
<span>Streak: {user.currentStreak ?? 0} 🔥 (best: {user.longestStreak ?? 0})</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>
|
||||
|
||||
{/* Display Name */}
|
||||
@@ -292,6 +316,25 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
||||
)}
|
||||
</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>}
|
||||
|
||||
{/* Delete */}
|
||||
@@ -419,6 +462,18 @@ function EmailTemplatesPanel({
|
||||
fieldKeys={['studyReengagementDays','studyReengagementT1Subject','studyReengagementT1Body','studyReengagementT1Cta','studyReengagementT2Subject','studyReengagementT2Body','studyReengagementT2Cta','studyReengagementT3Subject','studyReengagementT3Body','studyReengagementT3Cta']}
|
||||
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"
|
||||
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)"
|
||||
@@ -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: 'studyReengagementT3Body', label: 'Tier 3 — Body', multiline: true, 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: 'emailChangeBody', label: 'Body Text', multiline: true, section: 'email-templates' },
|
||||
{ key: 'emailChangeCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||||
@@ -1642,7 +1704,11 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
summary: '',
|
||||
url: '',
|
||||
embedUrl: '',
|
||||
showNotes: '',
|
||||
showNotesSummary: '',
|
||||
showNotesThemes: [],
|
||||
showNotesScripture: [],
|
||||
showNotesQuotes: [],
|
||||
showNotesResources: [],
|
||||
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) {
|
||||
const questions = raw.split('\n').map(q => q.trim()).filter(Boolean)
|
||||
setForm(f => ({
|
||||
@@ -3440,8 +3516,54 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`podcast-notes-${activeHighlight.id}`}>Show Notes</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)} />
|
||||
<label htmlFor={`podcast-notes-summary-${activeHighlight.id}`}>Episode Summary</label>
|
||||
<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 Prayer in suffering 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 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 & 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 https://example.com"
|
||||
value={(activeHighlight.showNotesResources ?? []).join('\n')}
|
||||
onChange={e => updatePodcastLinkStringArray(activeHighlight.id, 'showNotesResources', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`podcast-dq-${activeHighlight.id}`}>Discussion Questions (one per line)</label>
|
||||
|
||||
+264
-5
@@ -2668,17 +2668,20 @@
|
||||
|
||||
.note-anchor-btn {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
top: 0.85rem;
|
||||
right: 0.85rem;
|
||||
background: transparent;
|
||||
border: 1px solid #3a3828;
|
||||
border-radius: 6px;
|
||||
color: #5a5440;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.22rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--brand-font-body);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition: color 150ms, border-color 150ms, background 150ms;
|
||||
line-height: 1;
|
||||
line-height: 1.4;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -2699,6 +2702,184 @@
|
||||
border-color: rgba(224, 192, 112, 0.5);
|
||||
}
|
||||
|
||||
/* ── Progress bar ────────────────────────────────────────────────── */
|
||||
.study-lesson-progress-bar {
|
||||
height: 3px;
|
||||
background: rgba(201, 168, 76, 0.12);
|
||||
border-radius: 2px;
|
||||
margin: 0.35rem 0 0.1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.study-lesson-progress-fill {
|
||||
height: 100%;
|
||||
background: #c9a84c;
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease;
|
||||
min-width: 3px;
|
||||
}
|
||||
|
||||
/* ── Sidebar lesson navigator ────────────────────────────────────── */
|
||||
.study-lesson-nav-progress {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
color: #5a5440;
|
||||
margin-left: 0.4rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.study-lesson-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin-top: 0.25rem;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.study-lesson-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.42rem 0.55rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
transition: background 120ms, border-color 120ms;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.study-lesson-nav-item:hover {
|
||||
background: rgba(201, 168, 76, 0.06);
|
||||
}
|
||||
|
||||
.study-lesson-nav-item--current {
|
||||
background: rgba(201, 168, 76, 0.1);
|
||||
border-color: rgba(201, 168, 76, 0.2);
|
||||
}
|
||||
|
||||
.study-lesson-nav-item--locked {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.study-lesson-nav-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #5a5440;
|
||||
}
|
||||
|
||||
.study-lesson-nav-item--done .study-lesson-nav-icon {
|
||||
background: rgba(201, 168, 76, 0.18);
|
||||
color: #c9a84c;
|
||||
}
|
||||
|
||||
.study-lesson-nav-item--current .study-lesson-nav-icon {
|
||||
background: #c9a84c;
|
||||
color: #0e0d0b;
|
||||
}
|
||||
|
||||
.study-lesson-nav-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: #7a7060;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.study-lesson-nav-item--current .study-lesson-nav-label {
|
||||
color: #e2d4a8;
|
||||
}
|
||||
|
||||
.study-lesson-nav-item--done .study-lesson-nav-label {
|
||||
color: #5a5040;
|
||||
}
|
||||
|
||||
.study-lesson-nav-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* ── Notes count badge ────────────────────────────────────────────── */
|
||||
.study-notes-count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
background: rgba(201, 168, 76, 0.18);
|
||||
color: #c9a84c;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 0 5px;
|
||||
margin-left: 0.4rem;
|
||||
font-family: var(--brand-font-body);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── Inline reflection question answers ─────────────────────────── */
|
||||
.study-detail-list--questions {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.study-question-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.study-question-text {
|
||||
color: var(--brand-muted);
|
||||
font-size: 0.93rem;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.study-question-answer {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: rgba(201, 168, 76, 0.04);
|
||||
border: 1px solid rgba(201, 168, 76, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
font-size: 0.88rem;
|
||||
font-family: var(--brand-font-body);
|
||||
color: #f0ead8;
|
||||
resize: vertical;
|
||||
min-height: 72px;
|
||||
line-height: 1.6;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
|
||||
.study-question-answer:focus {
|
||||
outline: none;
|
||||
border-color: rgba(201, 168, 76, 0.4);
|
||||
}
|
||||
|
||||
.study-question-answer::placeholder {
|
||||
color: #3a3020;
|
||||
}
|
||||
|
||||
/* ── Sidebar anchor list ─────────────────────────────────────────── */
|
||||
.note-anchor-list {
|
||||
display: flex;
|
||||
@@ -12369,3 +12550,81 @@
|
||||
.admin-save-ok { color: #6ebd7c; font-size: 0.85rem; margin: 0; }
|
||||
.admin-save-error { color: #e05c5c; font-size: 0.85rem; margin: 0; }
|
||||
.admin-field-hint { font-size: 0.78rem; color: var(--admin-muted, #a0957e); margin: 0; }
|
||||
|
||||
/* ── Community feed ──────────────────────────────────────────────────────── */
|
||||
.community-feed { display: flex; flex-direction: column; gap: 0; }
|
||||
|
||||
.community-filter-bar { display: flex; gap: 0.4rem; flex-wrap: nowrap; overflow-x: auto; padding-bottom: 0.25rem; margin-bottom: 1.25rem; scrollbar-width: none; }
|
||||
.community-filter-bar::-webkit-scrollbar { display: none; }
|
||||
.community-filter-chip { flex-shrink: 0; padding: 0.3rem 0.85rem; border-radius: 999px; border: 1px solid var(--brand-border, #2a2518); background: transparent; color: var(--brand-muted, #b0a48c); font-size: 0.82rem; font-weight: 500; cursor: pointer; white-space: nowrap; transition: background 0.15s, color 0.15s, border-color 0.15s; }
|
||||
.community-filter-chip:hover { background: rgba(201,168,76,0.08); color: var(--brand-gold, #c9a84c); border-color: var(--brand-gold, #c9a84c); }
|
||||
.community-filter-chip--active { background: rgba(201,168,76,0.12); color: var(--brand-gold, #c9a84c); border-color: var(--brand-gold, #c9a84c); font-weight: 600; }
|
||||
|
||||
/* Avatar */
|
||||
.community-avatar { border-radius: 50%; object-fit: cover; flex-shrink: 0; display: block; }
|
||||
.community-avatar--initials { background: #2a2518; display: grid; place-items: center; color: var(--brand-warm-white, #f0ead8); font-weight: 700; font-family: var(--brand-font-body, sans-serif); line-height: 1; }
|
||||
|
||||
/* Compose card */
|
||||
.community-compose-card { display: flex; gap: 0.85rem; padding: 1.1rem 1.25rem; background: var(--brand-card-bg, #11100d); border: 1px solid var(--brand-border, #2a2518); border-radius: 14px; margin-bottom: 1.25rem; }
|
||||
.community-compose-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.community-compose-tag { display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; color: var(--brand-muted, #b0a48c); }
|
||||
.community-compose-tag strong { color: var(--brand-gold, #c9a84c); }
|
||||
.community-compose-tag-clear { background: none; border: none; color: var(--brand-muted, #b0a48c); cursor: pointer; font-size: 1rem; line-height: 1; padding: 0 0.1rem; }
|
||||
.community-compose-textarea { width: 100%; padding: 0.7rem 0; background: transparent; border: none; border-bottom: 1px solid var(--brand-border, #2a2518); color: var(--brand-warm-white, #f0ead8); font-family: var(--brand-font-body, sans-serif); font-size: 0.975rem; resize: none; overflow: hidden; outline: none; box-sizing: border-box; transition: border-color 0.15s; }
|
||||
.community-compose-textarea:focus { border-bottom-color: var(--brand-gold, #c9a84c); }
|
||||
.community-compose-textarea::placeholder { color: var(--brand-muted, #b0a48c); }
|
||||
.community-compose-footer { display: flex; justify-content: flex-end; }
|
||||
.community-post-btn { font-size: 0.875rem; padding: 0.4rem 1.1rem; border-radius: 999px; }
|
||||
|
||||
/* Status */
|
||||
.community-status-msg { color: var(--brand-muted, #b0a48c); font-size: 0.9rem; padding: 0.75rem 0; margin: 0; }
|
||||
.community-status-msg--error { color: #e07060; }
|
||||
|
||||
/* Post list */
|
||||
.community-posts-list { display: flex; flex-direction: column; }
|
||||
|
||||
/* Post card */
|
||||
.community-post-card { display: flex; gap: 0.75rem; padding: 1rem 0; border-bottom: 1px solid var(--brand-border, #2a2518); opacity: 1; transition: opacity 300ms; }
|
||||
.community-post-card--pending { opacity: 0.55; }
|
||||
.community-post-card:last-child { border-bottom: none; }
|
||||
.community-post-left { display: flex; flex-direction: column; align-items: center; gap: 0; padding-top: 0.15rem; }
|
||||
.community-thread-line { flex: 1; width: 2px; background: var(--brand-border, #2a2518); margin-top: 0.35rem; border-radius: 1px; min-height: 1rem; }
|
||||
.community-post-body { flex: 1; min-width: 0; }
|
||||
.community-post-header { display: flex; align-items: baseline; gap: 0.55rem; flex-wrap: wrap; margin-bottom: 0.25rem; }
|
||||
.community-post-author { font-weight: 700; font-size: 0.925rem; color: var(--brand-warm-white, #f0ead8); }
|
||||
.community-post-time { font-size: 0.8rem; color: var(--brand-muted, #b0a48c); }
|
||||
.community-lesson-chip { font-size: 0.73rem; font-weight: 500; padding: 0.15rem 0.55rem; border-radius: 999px; border: 1px solid var(--brand-border, #2a2518); background: transparent; color: var(--brand-muted, #b0a48c); cursor: pointer; transition: color 0.15s, border-color 0.15s; white-space: nowrap; }
|
||||
.community-lesson-chip:hover { color: var(--brand-gold, #c9a84c); border-color: var(--brand-gold, #c9a84c); }
|
||||
.community-post-text { font-size: 0.95rem; line-height: 1.6; color: var(--brand-warm-white, #f0ead8); white-space: pre-wrap; margin: 0 0 0.6rem; }
|
||||
|
||||
/* Engagement bar */
|
||||
.community-engagement-bar { display: flex; align-items: center; gap: 0.15rem; margin-bottom: 0.25rem; }
|
||||
.community-action-btn { display: flex; align-items: center; gap: 0.3rem; background: none; border: none; color: var(--brand-muted, #b0a48c); cursor: pointer; padding: 0.3rem 0.6rem; border-radius: 999px; font-size: 0.82rem; font-weight: 500; transition: color 0.15s, background 0.15s; line-height: 1; }
|
||||
.community-action-btn:hover { color: var(--brand-gold, #c9a84c); background: rgba(201,168,76,0.08); }
|
||||
.community-action-btn--liked .community-action-icon { fill: #e05c5c; color: #e05c5c; }
|
||||
.community-action-btn--liked { color: #e05c5c; }
|
||||
.community-action-btn--liked:hover { background: rgba(224,92,92,0.08); color: #e05c5c; }
|
||||
.community-action-btn--active { color: var(--brand-gold, #c9a84c); }
|
||||
.community-action-icon { width: 17px; height: 17px; fill: currentColor; flex-shrink: 0; }
|
||||
|
||||
/* Reply compose */
|
||||
.community-reply-compose { display: flex; gap: 0.65rem; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--brand-border, #2a2518); align-items: flex-start; }
|
||||
|
||||
/* Replies thread */
|
||||
.community-replies { display: flex; flex-direction: column; gap: 0; margin-top: 0.5rem; padding-top: 0.5rem; }
|
||||
.community-reply { display: flex; gap: 0.6rem; padding: 0.65rem 0; border-top: 1px solid color-mix(in srgb, var(--brand-border, #2a2518) 60%, transparent); }
|
||||
.community-reply:first-child { border-top: none; }
|
||||
.community-reply-body { flex: 1; min-width: 0; }
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.community-compose-card { padding: 0.9rem 1rem; }
|
||||
.community-post-card { gap: 0.55rem; padding: 0.85rem 0; }
|
||||
}
|
||||
|
||||
/* ── 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
@@ -716,6 +716,11 @@ function PodcastHighlightsSection({ content }: { content: SiteContent }) {
|
||||
|| isLikelySpotifyEpisodeUrl(link.url)
|
||||
|| (link.discussionQuestions ?? []).length > 0
|
||||
|| link.showNotes
|
||||
|| link.showNotesSummary
|
||||
|| (link.showNotesThemes ?? []).length > 0
|
||||
|| (link.showNotesScripture ?? []).length > 0
|
||||
|| (link.showNotesQuotes ?? []).length > 0
|
||||
|| (link.showNotesResources ?? []).length > 0
|
||||
)
|
||||
const dest = (!hasRichContent && link.url)
|
||||
? { external: link.url }
|
||||
@@ -1500,10 +1505,45 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
|
||||
</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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
+483
-267
File diff suppressed because it is too large.
Load diff
@@ -67,6 +67,13 @@ export interface PodcastFeaturedLink {
|
||||
summary: string
|
||||
url: string
|
||||
embedUrl?: string
|
||||
// Structured show notes fields
|
||||
showNotesSummary?: string
|
||||
showNotesThemes?: string[]
|
||||
showNotesScripture?: string[]
|
||||
showNotesQuotes?: string[]
|
||||
showNotesResources?: string[]
|
||||
// Legacy plain-text field — kept for backward compat with live data
|
||||
showNotes?: string
|
||||
discussionQuestions?: string[]
|
||||
}
|
||||
@@ -252,6 +259,15 @@ export interface SiteContent {
|
||||
studyReengagementT3Subject: string
|
||||
studyReengagementT3Body: string
|
||||
studyReengagementT3Cta: string
|
||||
// ── Study Completion Certificate Email ──
|
||||
studyCertificateEmailSubject: string
|
||||
studyCertificateEmailBody: string
|
||||
studyCertificateEmailCtaLabel: string
|
||||
studyCertificateEmailSignoff: string
|
||||
// ── Study Weekly Lesson Digest Email ──
|
||||
studyWeeklyDigestEmailSubject: string
|
||||
studyWeeklyDigestEmailBody: string
|
||||
studyWeeklyDigestEmailSignoff: string
|
||||
// ── Email Change Confirmation ──
|
||||
emailChangeSubject: string
|
||||
emailChangeBody: string
|
||||
@@ -465,6 +481,13 @@ export const DEFAULTS: SiteContent = {
|
||||
studyReengagementT3Subject: 'Your progress is still here',
|
||||
studyReengagementT3Body: "Your study progress is still intact and waiting. There's no deadline — come back whenever you're ready.",
|
||||
studyReengagementT3Cta: 'Open Your Study',
|
||||
studyCertificateEmailSubject: 'Your certificate is ready',
|
||||
studyCertificateEmailBody: 'Congratulations — you have completed every lesson in this study. Your certificate is ready to view and share.',
|
||||
studyCertificateEmailCtaLabel: 'View Certificate',
|
||||
studyCertificateEmailSignoff: 'Grace and peace,\nVerse by Verse with Nate',
|
||||
studyWeeklyDigestEmailSubject: 'Your weekly study digest',
|
||||
studyWeeklyDigestEmailBody: 'Here are the lessons released this week in your study track. Open any to continue where you left off.',
|
||||
studyWeeklyDigestEmailSignoff: 'Grace and peace,\nVerse by Verse with Nate',
|
||||
emailChangeSubject: 'Confirm your new email address',
|
||||
emailChangeBody: 'Click the link below to confirm your new account email. If you did not request this change, ignore this message.',
|
||||
emailChangeCtaLabel: 'Confirm Email Change',
|
||||
|
||||
Reference in new issue
Block a user