From 57fe34ba47ac9784495050e12889278e9d092987 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Tue, 28 Jul 2026 16:13:25 -0400 Subject: [PATCH] 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 --- package.json | 2 +- server/email.js | 45 +++++++++++++++++------------------------ server/study-helpers.js | 15 ++++++++------ src/AdminPage.tsx | 16 +++++++++++++++ src/content.ts | 21 +++++++++++++++++++ 5 files changed, 66 insertions(+), 33 deletions(-) diff --git a/package.json b/package.json index 1001c0c..08ec5b8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.19", + "version": "1.1.20", "type": "module", "scripts": { "dev": "vite", diff --git a/server/email.js b/server/email.js index 4834747..471756c 100644 --- a/server/email.js +++ b/server/email.js @@ -585,36 +585,29 @@ export async function sendStudyReminderEmail(email, displayName, studyTitle, sec } } -const REENGAGEMENT_COPY = { - '7d': { - subject: 'Your study is waiting for you', - eyebrow: 'Come Back', - 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', - }, - '14d': { - subject: "Don't lose your momentum", - eyebrow: 'Still With You', - headline: 'Your spot is still saved', - body: 'Two weeks have passed, but every note and completed lesson is still right there. A little each day adds up.', - cta: 'Return to Your Study', - }, - '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', - }, +const REENGAGEMENT_DEFAULTS = { + 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' }, + 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' }, + 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' }, +} + +function getReengagementCopy(tier) { + const cfg = state.cachedSiteContent ?? {} + const t = tier === 't1' ? '1' : tier === 't2' ? '2' : tier === 't3' ? '3' : null + const def = REENGAGEMENT_DEFAULTS[tier] ?? REENGAGEMENT_DEFAULTS.t1 + if (!t) return def + return { + subject: cfg[`studyReengagementT${t}Subject`]?.trim() || def.subject, + body: cfg[`studyReengagementT${t}Body`]?.trim() || def.body, + cta: cfg[`studyReengagementT${t}Cta`]?.trim() || def.cta, + } } export async function sendStudyReengagementEmail(email, displayName, studyTitle, studyUrl, tier, unsubUrl) { if (!process.env.RESEND_API_KEY) return - const copy = REENGAGEMENT_COPY[tier] - if (!copy) return try { const resend = new Resend(process.env.RESEND_API_KEY) + const copy = getReengagementCopy(tier) const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' const subject = `${copy.subject}${studyTitle ? ` — ${studyTitle}` : ''}` const bodyHtml = ( @@ -629,8 +622,8 @@ export async function sendStudyReengagementEmail(email, displayName, studyTitle, 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}` : ''}`, html: buildBrandedEmailHtml({ - title: copy.headline, - eyebrow: copy.eyebrow, + title: copy.subject, + eyebrow: 'Study Reminder', bodyHtml, ctaLabel: copy.cta, ctaUrl: studyUrl, diff --git a/server/study-helpers.js b/server/study-helpers.js index ede0278..a002661 100644 --- a/server/study-helpers.js +++ b/server/study-helpers.js @@ -669,11 +669,13 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) { // ── Study re-engagement scheduler ───────────────────────────────────────── -const REENGAGEMENT_TIERS = [ - { key: '7d', days: 7 }, - { key: '14d', days: 14 }, - { key: '30d', days: 30 }, -] +function getReengagementTiers() { + const cfg = state.cachedSiteContent ?? {} + const raw = typeof cfg.studyReengagementDays === 'string' ? cfg.studyReengagementDays : '7,14,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) { const secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge' @@ -687,6 +689,7 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) { if (!state.cachedSiteContent) return const now = new Date() const nowMs = now.getTime() + const tiers = getReengagementTiers() for (const user of state.studyUsers) { if (user.studyRemindersEnabled !== true) continue @@ -713,7 +716,7 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) { ? `${base}/study/${firstStudy.slug}` : `${base}/study` - for (const tier of REENGAGEMENT_TIERS) { + for (const tier of tiers) { if (daysSinceLogin < tier.days) continue if (sent[tier.key]) continue diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 9f2b6d6..bf5bdd9 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -478,6 +478,12 @@ function EmailTemplatesPanel({ fieldKeys={['studyReminderEmailSubjectPrefix','studyReminderEmailBody','studyReminderEmailCtaLabel','studyReminderEmailSignoff']} openKey="study-reminder" /> + +