Add study account engagement features; v1.2.4
- Pause reminders: remindersPausedUntil field + scheduler skip + account UI with 7/14/30-day presets - Email digest mode: reminderFrequency field (immediate/weekly) + weekly digest email + scheduler + account UI toggle - Admin user list improvements: streak, reengagement, frequency, pause, per-study lesson progress - Admin manual re-engagement: POST endpoint + tier selector button in admin panel - Study bookmarks: bookmarkedSectionIds in progress file + POST/DELETE endpoints + toggle button in lesson UI - Study completion certificate email: auto-sent on first certificate issuance - Content fields for certificate and weekly digest email templates in admin Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
5bc8245ed2
commit
038ef2b039
12 files changed
+540
-38
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.3",
|
"version": "1.2.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
+80
-1
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-1
@@ -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' },
|
||||||
|
|||||||
+165
-31
@@ -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 = {
|
||||||
@@ -1256,7 +1259,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 +1308,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,6 +1502,8 @@ 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>>({})
|
||||||
@@ -1568,14 +1573,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
|
||||||
@@ -2017,30 +2024,56 @@ 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>
|
||||||
<button
|
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
type="button"
|
<button
|
||||||
className={lessonCompleted ? 'btn-secondary' : 'btn-primary'}
|
type="button"
|
||||||
style={lessonCompleted ? { borderColor: '#2196f3', color: '#2196f3' } : {}}
|
className={lessonCompleted ? 'btn-secondary' : 'btn-primary'}
|
||||||
disabled={progressSaving}
|
style={lessonCompleted ? { borderColor: '#2196f3', color: '#2196f3' } : {}}
|
||||||
onClick={async () => {
|
disabled={progressSaving}
|
||||||
if (!study || !section?.id) return
|
onClick={async () => {
|
||||||
setProgressSaving(true)
|
if (!study || !section?.id) return
|
||||||
const wasComplete = lessonCompleted
|
setProgressSaving(true)
|
||||||
try {
|
const wasComplete = lessonCompleted
|
||||||
const method = wasComplete ? 'DELETE' : 'POST'
|
try {
|
||||||
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(
|
const method = wasComplete ? 'DELETE' : 'POST'
|
||||||
`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`,
|
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(
|
||||||
{ method }
|
`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`,
|
||||||
)
|
{ method }
|
||||||
const newIds = Array.isArray(data.completedSectionIds) ? data.completedSectionIds : []
|
)
|
||||||
setCompletedSectionIds(newIds)
|
const newIds = Array.isArray(data.completedSectionIds) ? data.completedSectionIds : []
|
||||||
if (!wasComplete) checkAndShowCelebration(newIds, false)
|
setCompletedSectionIds(newIds)
|
||||||
} catch { /* ignore */ }
|
if (!wasComplete) checkAndShowCelebration(newIds, false)
|
||||||
finally { setProgressSaving(false) }
|
} catch { /* ignore */ }
|
||||||
}}
|
finally { setProgressSaving(false) }
|
||||||
>
|
}}
|
||||||
{progressSaving ? '…' : lessonCompleted ? '✓ Completed' : 'Mark as Completed'}
|
>
|
||||||
</button>
|
{progressSaving ? '…' : lessonCompleted ? '✓ Completed' : 'Mark as Completed'}
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2439,6 +2472,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 +2544,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 +2578,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 +2639,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 +3049,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>
|
||||||
|
|||||||
@@ -259,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
|
||||||
@@ -472,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',
|
||||||
|
|||||||
Reference in new issue
Block a user