Make re-engagement email tiers configurable from admin; v1.1.20
Day thresholds and per-tier subject/body/CTA are now editable in the Email Templates panel. Tier keys are position-based (t1/t2/t3) so changing day values never re-sends emails already fired. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.19",
|
"version": "1.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+19
-26
@@ -585,36 +585,29 @@ export async function sendStudyReminderEmail(email, displayName, studyTitle, sec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const REENGAGEMENT_COPY = {
|
const REENGAGEMENT_DEFAULTS = {
|
||||||
'7d': {
|
t1: { subject: 'Your study is waiting for you', body: "It's been a while — your progress is saved and your next lesson is ready whenever you are.", cta: 'Continue Studying' },
|
||||||
subject: 'Your study is waiting for you',
|
t2: { subject: "Don't lose your momentum", body: 'Every note and completed lesson is still right there. A little each day adds up — come back and continue.', cta: 'Return to Your Study' },
|
||||||
eyebrow: 'Come Back',
|
t3: { subject: 'Your progress is still here', body: "Your study progress is still intact and waiting. There's no deadline — come back whenever you're ready.", cta: 'Open Your Study' },
|
||||||
headline: 'Pick up where you left off',
|
}
|
||||||
body: "It's been a week — your progress is saved and your next lesson is ready whenever you are.",
|
|
||||||
cta: 'Continue Studying',
|
function getReengagementCopy(tier) {
|
||||||
},
|
const cfg = state.cachedSiteContent ?? {}
|
||||||
'14d': {
|
const t = tier === 't1' ? '1' : tier === 't2' ? '2' : tier === 't3' ? '3' : null
|
||||||
subject: "Don't lose your momentum",
|
const def = REENGAGEMENT_DEFAULTS[tier] ?? REENGAGEMENT_DEFAULTS.t1
|
||||||
eyebrow: 'Still With You',
|
if (!t) return def
|
||||||
headline: 'Your spot is still saved',
|
return {
|
||||||
body: 'Two weeks have passed, but every note and completed lesson is still right there. A little each day adds up.',
|
subject: cfg[`studyReengagementT${t}Subject`]?.trim() || def.subject,
|
||||||
cta: 'Return to Your Study',
|
body: cfg[`studyReengagementT${t}Body`]?.trim() || def.body,
|
||||||
},
|
cta: cfg[`studyReengagementT${t}Cta`]?.trim() || def.cta,
|
||||||
'30d': {
|
}
|
||||||
subject: 'Your progress is still here',
|
|
||||||
eyebrow: 'We Saved Your Spot',
|
|
||||||
headline: "It's been a month — come back anytime",
|
|
||||||
body: 'Your study progress is still intact and waiting. There\'s no deadline — come back whenever you\'re ready.',
|
|
||||||
cta: 'Open Your Study',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendStudyReengagementEmail(email, displayName, studyTitle, studyUrl, tier, unsubUrl) {
|
export async function sendStudyReengagementEmail(email, displayName, studyTitle, studyUrl, tier, unsubUrl) {
|
||||||
if (!process.env.RESEND_API_KEY) return
|
if (!process.env.RESEND_API_KEY) return
|
||||||
const copy = REENGAGEMENT_COPY[tier]
|
|
||||||
if (!copy) return
|
|
||||||
try {
|
try {
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
const copy = getReengagementCopy(tier)
|
||||||
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
||||||
const subject = `${copy.subject}${studyTitle ? ` — ${studyTitle}` : ''}`
|
const subject = `${copy.subject}${studyTitle ? ` — ${studyTitle}` : ''}`
|
||||||
const bodyHtml = (
|
const bodyHtml = (
|
||||||
@@ -629,8 +622,8 @@ export async function sendStudyReengagementEmail(email, displayName, studyTitle,
|
|||||||
subject,
|
subject,
|
||||||
text: `Hi ${namePart},\n\n${copy.body}${studyTitle ? ` Your current study: ${studyTitle}.` : ''}\n\n${studyUrl}\n\nGrace and peace,\nVerse by Verse with Nate${unsubUrl ? `\n\nUnsubscribe: ${unsubUrl}` : ''}`,
|
text: `Hi ${namePart},\n\n${copy.body}${studyTitle ? ` Your current study: ${studyTitle}.` : ''}\n\n${studyUrl}\n\nGrace and peace,\nVerse by Verse with Nate${unsubUrl ? `\n\nUnsubscribe: ${unsubUrl}` : ''}`,
|
||||||
html: buildBrandedEmailHtml({
|
html: buildBrandedEmailHtml({
|
||||||
title: copy.headline,
|
title: copy.subject,
|
||||||
eyebrow: copy.eyebrow,
|
eyebrow: 'Study Reminder',
|
||||||
bodyHtml,
|
bodyHtml,
|
||||||
ctaLabel: copy.cta,
|
ctaLabel: copy.cta,
|
||||||
ctaUrl: studyUrl,
|
ctaUrl: studyUrl,
|
||||||
|
|||||||
@@ -669,11 +669,13 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) {
|
|||||||
|
|
||||||
// ── Study re-engagement scheduler ─────────────────────────────────────────
|
// ── Study re-engagement scheduler ─────────────────────────────────────────
|
||||||
|
|
||||||
const REENGAGEMENT_TIERS = [
|
function getReengagementTiers() {
|
||||||
{ key: '7d', days: 7 },
|
const cfg = state.cachedSiteContent ?? {}
|
||||||
{ key: '14d', days: 14 },
|
const raw = typeof cfg.studyReengagementDays === 'string' ? cfg.studyReengagementDays : '7,14,30'
|
||||||
{ key: '30d', days: 30 },
|
const days = raw.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n > 0)
|
||||||
]
|
if (days.length === 0) return [{ key: 't1', days: 7 }, { key: 't2', days: 14 }, { key: 't3', days: 30 }]
|
||||||
|
return days.slice(0, 3).map((d, i) => ({ key: `t${i + 1}`, days: d }))
|
||||||
|
}
|
||||||
|
|
||||||
function makeUnsubUrl(userId) {
|
function makeUnsubUrl(userId) {
|
||||||
const secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge'
|
const secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge'
|
||||||
@@ -687,6 +689,7 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
|
|||||||
if (!state.cachedSiteContent) return
|
if (!state.cachedSiteContent) return
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const nowMs = now.getTime()
|
const nowMs = now.getTime()
|
||||||
|
const tiers = getReengagementTiers()
|
||||||
|
|
||||||
for (const user of state.studyUsers) {
|
for (const user of state.studyUsers) {
|
||||||
if (user.studyRemindersEnabled !== true) continue
|
if (user.studyRemindersEnabled !== true) continue
|
||||||
@@ -713,7 +716,7 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
|
|||||||
? `${base}/study/${firstStudy.slug}`
|
? `${base}/study/${firstStudy.slug}`
|
||||||
: `${base}/study`
|
: `${base}/study`
|
||||||
|
|
||||||
for (const tier of REENGAGEMENT_TIERS) {
|
for (const tier of tiers) {
|
||||||
if (daysSinceLogin < tier.days) continue
|
if (daysSinceLogin < tier.days) continue
|
||||||
if (sent[tier.key]) continue
|
if (sent[tier.key]) continue
|
||||||
|
|
||||||
|
|||||||
@@ -478,6 +478,12 @@ function EmailTemplatesPanel({
|
|||||||
fieldKeys={['studyReminderEmailSubjectPrefix','studyReminderEmailBody','studyReminderEmailCtaLabel','studyReminderEmailSignoff']}
|
fieldKeys={['studyReminderEmailSubjectPrefix','studyReminderEmailBody','studyReminderEmailCtaLabel','studyReminderEmailSignoff']}
|
||||||
openKey="study-reminder" />
|
openKey="study-reminder" />
|
||||||
|
|
||||||
|
<EmailTemplateCard type="study" icon="💤" title="Study Account — Re-engagement Reminders"
|
||||||
|
trigger="A student with reminders enabled hasn't logged in for the configured number of days (fires up to 3 tiers, one per check)"
|
||||||
|
autoVars="Student's name, study title, study URL, and a one-click unsubscribe link are auto-inserted"
|
||||||
|
fieldKeys={['studyReengagementDays','studyReengagementT1Subject','studyReengagementT1Body','studyReengagementT1Cta','studyReengagementT2Subject','studyReengagementT2Body','studyReengagementT2Cta','studyReengagementT3Subject','studyReengagementT3Body','studyReengagementT3Cta']}
|
||||||
|
openKey="study-reengagement" />
|
||||||
|
|
||||||
<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)"
|
||||||
@@ -972,6 +978,16 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect
|
|||||||
{ key: 'studyReminderEmailBody', label: 'Body Paragraph', multiline: true, section: 'email-templates' },
|
{ key: 'studyReminderEmailBody', label: 'Body Paragraph', multiline: true, section: 'email-templates' },
|
||||||
{ key: 'studyReminderEmailCtaLabel', label: 'Button Label', section: 'email-templates' },
|
{ key: 'studyReminderEmailCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||||||
{ key: 'studyReminderEmailSignoff', label: 'Signoff', multiline: true, section: 'email-templates' },
|
{ key: 'studyReminderEmailSignoff', label: 'Signoff', multiline: true, section: 'email-templates' },
|
||||||
|
{ key: 'studyReengagementDays', label: 'Inactivity thresholds — comma-separated days for each tier (e.g. "7,14,30")', section: 'email-templates' },
|
||||||
|
{ key: 'studyReengagementT1Subject', label: 'Tier 1 — Subject Line', section: 'email-templates' },
|
||||||
|
{ key: 'studyReengagementT1Body', label: 'Tier 1 — Body', multiline: true, section: 'email-templates' },
|
||||||
|
{ key: 'studyReengagementT1Cta', label: 'Tier 1 — Button Label', section: 'email-templates' },
|
||||||
|
{ key: 'studyReengagementT2Subject', label: 'Tier 2 — Subject Line', section: 'email-templates' },
|
||||||
|
{ key: 'studyReengagementT2Body', label: 'Tier 2 — Body', multiline: true, section: 'email-templates' },
|
||||||
|
{ key: 'studyReengagementT2Cta', label: 'Tier 2 — Button Label', 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: 'studyReengagementT3Cta', label: 'Tier 3 — Button Label', 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' },
|
||||||
|
|||||||
@@ -231,6 +231,17 @@ export interface SiteContent {
|
|||||||
studyReminderEmailBody: string
|
studyReminderEmailBody: string
|
||||||
studyReminderEmailCtaLabel: string
|
studyReminderEmailCtaLabel: string
|
||||||
studyReminderEmailSignoff: string
|
studyReminderEmailSignoff: string
|
||||||
|
// ── Study Re-engagement Emails ──
|
||||||
|
studyReengagementDays: string
|
||||||
|
studyReengagementT1Subject: string
|
||||||
|
studyReengagementT1Body: string
|
||||||
|
studyReengagementT1Cta: string
|
||||||
|
studyReengagementT2Subject: string
|
||||||
|
studyReengagementT2Body: string
|
||||||
|
studyReengagementT2Cta: string
|
||||||
|
studyReengagementT3Subject: string
|
||||||
|
studyReengagementT3Body: string
|
||||||
|
studyReengagementT3Cta: string
|
||||||
// ── Email Change Confirmation ──
|
// ── Email Change Confirmation ──
|
||||||
emailChangeSubject: string
|
emailChangeSubject: string
|
||||||
emailChangeBody: string
|
emailChangeBody: string
|
||||||
@@ -429,6 +440,16 @@ export const DEFAULTS: SiteContent = {
|
|||||||
studyReminderEmailBody: 'A new lesson has been unlocked in your study track. Open it below to continue where you left off.',
|
studyReminderEmailBody: 'A new lesson has been unlocked in your study track. Open it below to continue where you left off.',
|
||||||
studyReminderEmailCtaLabel: 'Open the Lesson',
|
studyReminderEmailCtaLabel: 'Open the Lesson',
|
||||||
studyReminderEmailSignoff: 'Grace and peace,\nVerse by Verse with Nate',
|
studyReminderEmailSignoff: 'Grace and peace,\nVerse by Verse with Nate',
|
||||||
|
studyReengagementDays: '7,14,30',
|
||||||
|
studyReengagementT1Subject: 'Your study is waiting for you',
|
||||||
|
studyReengagementT1Body: "It's been a while — your progress is saved and your next lesson is ready whenever you are.",
|
||||||
|
studyReengagementT1Cta: 'Continue Studying',
|
||||||
|
studyReengagementT2Subject: "Don't lose your momentum",
|
||||||
|
studyReengagementT2Body: "Every note and completed lesson is still right there. A little each day adds up — come back and continue.",
|
||||||
|
studyReengagementT2Cta: 'Return to Your Study',
|
||||||
|
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',
|
||||||
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