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:
nmemmertandClaude Sonnet 4.6 committed 2026-09-03 15:16:47 -04:00
1 parent 5bc8245ed2
commit 038ef2b039
12 files changed
+540 -38

No files matched your search

+63 -1
View File
@@ -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' },