From a2c49241914c441383693357b8c392d14d4b1554 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Wed, 3 Jun 2026 13:14:08 -0400 Subject: [PATCH] email templates --- server.js | 109 ++++++++++++++++++-------------- server/helpers.js | 20 ++++++ src/AdminPage.tsx | 157 +++++++++++++++++++++++++++++++++++++++++++++- src/App.css | 109 ++++++++++++++++++++++++++++++++ src/content.ts | 45 +++++++++++++ 5 files changed, 389 insertions(+), 51 deletions(-) diff --git a/server.js b/server.js index 72bb03d..6e4c5cd 100644 --- a/server.js +++ b/server.js @@ -1094,6 +1094,9 @@ function buildContactWelcomeEmailTemplate({ welcomeWebsiteUrl, welcomeEpisodeUrl, welcomeImageUrl, + welcomeSpotifyBtnLabel = 'Listen on Spotify', + welcomeAppleBtnLabel = 'Apple Podcasts', + welcomeStartHereLinkLabel = 'Open Start Here page', }) { const welcomeSignoffHtml = escapeHtml(welcomeSignoff).replace(/\n/g, '
') @@ -1142,10 +1145,10 @@ function buildContactWelcomeEmailTemplate({ `

${escapeHtml(welcomeStartHereTitle)}

` + `

${escapeHtml(welcomeStartHereSummary)}

` + `` + - `` + - `` + + `` + + `` + `
Listen on SpotifyApple Podcasts${escapeHtml(welcomeSpotifyBtnLabel)}${escapeHtml(welcomeAppleBtnLabel)}
` + - `

Open Start Here page

` + + `

${escapeHtml(welcomeStartHereLinkLabel)}

` + `` + `
` + `` + @@ -2433,31 +2436,30 @@ async function sendStudyWelcomeEmail(email, displayName) { if (!process.env.RESEND_API_KEY) return try { const resend = new Resend(process.env.RESEND_API_KEY) + const cfg = cachedSiteContent ?? {} const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' const baseUrl = getCanonicalBaseUrl() - const studiesUrl = buildAbsoluteUrl(baseUrl, '/study') const accountUrl = buildAbsoluteUrl(baseUrl, '/study/account') + const subject = cfg.studyWelcomeEmailSubject?.trim() || 'Welcome to the Study Community' + const bodyText = cfg.studyWelcomeEmailBody?.trim() || 'Your student account is ready. Open your studies and continue learning, or manage your account details anytime.' + const ctaLabel = cfg.studyWelcomeEmailCtaLabel?.trim() || 'Open Studies' + const studiesUrl = buildAbsoluteUrl(baseUrl, cfg.studyWelcomeEmailCtaPath?.trim() || '/study') + const signoff = cfg.studyWelcomeEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate' const bodyHtml = ( `

Welcome, ${escapeHtml(namePart)}.

` + - `

Your student account is ready.

` + - `

Open studies and continue learning, or manage your account details anytime.

` + `

${escapeHtml(bodyText)}

` ) - const footerHtml = `

Grace and peace,
Verse by Verse with Nate

` + const footerHtml = `

${escapeHtml(signoff).replace(/\n/g, '
')}

` const { error } = await resend.emails.send({ from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', to: [email], - subject: process.env.RESEND_WELCOME_SUBJECT ?? 'Welcome to Verse by Verse with Nate', - text: - `Welcome, ${namePart}.\n\n` + - `Your student account is ready.\n\n` + - `Open studies: ${studiesUrl}\n` + - `Manage account: ${accountUrl}\n\n` + - `Grace and peace,\nVerse by Verse with Nate`, + subject, + text: `Welcome, ${namePart}.\n\n${bodyText}\n\nOpen studies: ${studiesUrl}\nManage account: ${accountUrl}\n\n${signoff}`, html: buildBrandedEmailHtml({ title: 'Welcome to the Study Community', eyebrow: 'Study Account', bodyHtml, - ctaLabel: 'Open Studies', + ctaLabel, ctaUrl: studiesUrl, footerHtml: footerHtml + `

Manage your account

`, }), @@ -2472,30 +2474,29 @@ async function sendStudyAccountDeletedEmail(email, displayName) { if (!process.env.RESEND_API_KEY) return try { const resend = new Resend(process.env.RESEND_API_KEY) + const cfg = cachedSiteContent ?? {} const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' const baseUrl = getCanonicalBaseUrl() - const signupUrl = buildAbsoluteUrl(baseUrl, '/study/signup') + const subject = cfg.studyDeletedEmailSubject?.trim() || 'Your study account was deleted' + const bodyText = cfg.studyDeletedEmailBody?.trim() || 'This confirms your study account and saved notes were deleted. If this was not you, please contact us immediately.' + const ctaLabel = cfg.studyDeletedEmailCtaLabel?.trim() || 'Create a New Account' + const signupUrl = buildAbsoluteUrl(baseUrl, cfg.studyDeletedEmailCtaPath?.trim() || '/study/signup') + const signoff = cfg.studyDeletedEmailSignoff?.trim() || 'Verse by Verse with Nate' const bodyHtml = ( `

Hi ${escapeHtml(namePart)},

` + - `

This confirms your study account and saved notes were deleted.

` + - `

If this was not you, please contact us immediately.

` + `

${escapeHtml(bodyText)}

` ) - const footerHtml = `

Verse by Verse with Nate

` + const footerHtml = `

${escapeHtml(signoff).replace(/\n/g, '
')}

` const { error } = await resend.emails.send({ from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', to: [email], - subject: 'Your study account was deleted', - text: - `Hi ${namePart},\n\n` + - `This confirms your study account and saved notes were deleted.\n\n` + - `If this was not you, please contact us immediately.\n\n` + - `Create a new account anytime: ${signupUrl}\n\n` + - `Verse by Verse with Nate`, + subject, + text: `Hi ${namePart},\n\n${bodyText}\n\nCreate a new account anytime: ${signupUrl}\n\n${signoff}`, html: buildBrandedEmailHtml({ title: 'Study Account Deleted', eyebrow: 'Account Update', bodyHtml, - ctaLabel: 'Create a New Account', + ctaLabel, ctaUrl: signupUrl, footerHtml, }), @@ -2782,28 +2783,29 @@ async function sendStudyReminderEmail(email, displayName, studyTitle, sectionTit if (!process.env.RESEND_API_KEY) return try { const resend = new Resend(process.env.RESEND_API_KEY) + const cfg = cachedSiteContent ?? {} const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' + const subjectPrefix = cfg.studyReminderEmailSubjectPrefix?.trim() || 'New lesson available:' + const bodyText = cfg.studyReminderEmailBody?.trim() || 'A new lesson has been unlocked in your study track. Open it below to continue where you left off.' + const ctaLabel = cfg.studyReminderEmailCtaLabel?.trim() || 'Open the Lesson' + const signoff = cfg.studyReminderEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate' + const subject = process.env.RESEND_REMINDER_SUBJECT ?? `${subjectPrefix} ${sectionTitle}` const bodyHtml = ( `

Hi ${escapeHtml(namePart)},

` + - `

A new study lesson is available in ${escapeHtml(studyTitle)}.

` + - `

${escapeHtml(sectionTitle)} (${escapeHtml(sectionReference)}) is now unlocked.

` + `

${escapeHtml(bodyText)}

` + + `

${escapeHtml(sectionTitle)} (${escapeHtml(sectionReference)}) — ${escapeHtml(studyTitle)}

` ) - const footerHtml = `

Grace and peace,
Verse by Verse with Nate

` + const footerHtml = `

${escapeHtml(signoff).replace(/\n/g, '
')}

` const { error } = await resend.emails.send({ from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', to: [email], - subject: process.env.RESEND_REMINDER_SUBJECT ?? `New lesson available: ${sectionTitle}`, - text: - `Hi ${namePart},\n\n` + - `A new lesson is available in ${studyTitle}.\n` + - `${sectionTitle} (${sectionReference}) is now unlocked.\n\n` + - `Open it here: ${sectionUrl}\n\n` + - `Grace and peace,\nVerse by Verse with Nate`, + subject, + text: `Hi ${namePart},\n\n${bodyText}\n\n${sectionTitle} (${sectionReference}) — ${studyTitle}\n\nOpen it here: ${sectionUrl}\n\n${signoff}`, html: buildBrandedEmailHtml({ title: 'New Lesson Available', eyebrow: 'Study Reminder', bodyHtml, - ctaLabel: 'Open the Lesson', + ctaLabel, ctaUrl: sectionUrl, footerHtml, }), @@ -3790,19 +3792,23 @@ app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requir const resend = new Resend(process.env.RESEND_API_KEY) const baseUrl = getCanonicalBaseUrl() const verifyUrl = buildAbsoluteUrl(baseUrl, `/study/account?verifyEmailToken=${encodeURIComponent(rawToken)}`) + const cfg = cachedSiteContent ?? {} + const emailChangeSubject = cfg.emailChangeSubject?.trim() || 'Confirm your new email address' + const emailChangeBody = cfg.emailChangeBody?.trim() || 'Click the link below to confirm your new account email. If you did not request this change, ignore this message.' + const emailChangeCtaLabel = cfg.emailChangeCtaLabel?.trim() || 'Confirm Email Change' const { error } = await resend.emails.send({ from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', to: [newEmail], - subject: 'Confirm your new email address', - text: - `Use this link to confirm your new account email:\n${verifyUrl}\n\n` + - `If you did not request this change, ignore this message.`, - html: - `
` + - `

Click the link below to confirm your new account email:

` + - `

Confirm email change

` + - `

If you did not request this change, ignore this message.

` + - `
`, + subject: emailChangeSubject, + text: `${emailChangeBody}\n\n${verifyUrl}`, + html: buildBrandedEmailHtml({ + title: emailChangeSubject, + eyebrow: 'Account Security', + bodyHtml: `

${escapeHtml(emailChangeBody)}

`, + ctaLabel: emailChangeCtaLabel, + ctaUrl: verifyUrl, + footerHtml: `

Verse by Verse with Nate

`, + }), }) if (error) { console.error('[study-account] email change send error:', error) @@ -5013,6 +5019,10 @@ app.post('/api/contact', contactRateLimit, async (req, res) => { process.env.RESEND_WELCOME_IMAGE_URL ?? emailConfig.welcomeEmailImageUrl ?? '/images/podcast-art.jpeg', ) + const welcomeSpotifyBtnLabel = emailConfig.welcomeEmailSpotifyBtnLabel?.trim() || 'Listen on Spotify' + const welcomeAppleBtnLabel = emailConfig.welcomeEmailAppleBtnLabel?.trim() || 'Apple Podcasts' + const welcomeStartHereLinkLabel = emailConfig.welcomeEmailStartHereLinkLabel?.trim() || 'Open Start Here page' + const welcomeTemplate = buildContactWelcomeEmailTemplate({ greetingName, welcomeIntro, @@ -5032,6 +5042,9 @@ app.post('/api/contact', contactRateLimit, async (req, res) => { welcomeWebsiteUrl, welcomeEpisodeUrl, welcomeImageUrl, + welcomeSpotifyBtnLabel, + welcomeAppleBtnLabel, + welcomeStartHereLinkLabel, }) try { diff --git a/server/helpers.js b/server/helpers.js index 41388b2..8eaca09 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -339,6 +339,26 @@ export function sanitizeSiteContent(siteContent) { redirects: sanitizeRedirectRules(siteContent.redirects), podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS), episodesSeoIntro: typeof siteContent.episodesSeoIntro === 'string' && siteContent.episodesSeoIntro.trim() ? siteContent.episodesSeoIntro.trim().slice(0, 600) : '', + welcomeEmailSpotifyBtnLabel: typeof siteContent.welcomeEmailSpotifyBtnLabel === 'string' ? siteContent.welcomeEmailSpotifyBtnLabel.trim().slice(0, 80) : '', + welcomeEmailAppleBtnLabel: typeof siteContent.welcomeEmailAppleBtnLabel === 'string' ? siteContent.welcomeEmailAppleBtnLabel.trim().slice(0, 80) : '', + welcomeEmailStartHereLinkLabel: typeof siteContent.welcomeEmailStartHereLinkLabel === 'string' ? siteContent.welcomeEmailStartHereLinkLabel.trim().slice(0, 80) : '', + studyWelcomeEmailSubject: typeof siteContent.studyWelcomeEmailSubject === 'string' ? siteContent.studyWelcomeEmailSubject.trim().slice(0, 200) : '', + studyWelcomeEmailBody: typeof siteContent.studyWelcomeEmailBody === 'string' ? siteContent.studyWelcomeEmailBody.trim().slice(0, 1000) : '', + studyWelcomeEmailCtaLabel: typeof siteContent.studyWelcomeEmailCtaLabel === 'string' ? siteContent.studyWelcomeEmailCtaLabel.trim().slice(0, 80) : '', + studyWelcomeEmailCtaPath: typeof siteContent.studyWelcomeEmailCtaPath === 'string' ? siteContent.studyWelcomeEmailCtaPath.trim().slice(0, 200) : '', + studyWelcomeEmailSignoff: typeof siteContent.studyWelcomeEmailSignoff === 'string' ? siteContent.studyWelcomeEmailSignoff.trim().slice(0, 200) : '', + studyDeletedEmailSubject: typeof siteContent.studyDeletedEmailSubject === 'string' ? siteContent.studyDeletedEmailSubject.trim().slice(0, 200) : '', + studyDeletedEmailBody: typeof siteContent.studyDeletedEmailBody === 'string' ? siteContent.studyDeletedEmailBody.trim().slice(0, 1000) : '', + studyDeletedEmailCtaLabel: typeof siteContent.studyDeletedEmailCtaLabel === 'string' ? siteContent.studyDeletedEmailCtaLabel.trim().slice(0, 80) : '', + studyDeletedEmailCtaPath: typeof siteContent.studyDeletedEmailCtaPath === 'string' ? siteContent.studyDeletedEmailCtaPath.trim().slice(0, 200) : '', + studyDeletedEmailSignoff: typeof siteContent.studyDeletedEmailSignoff === 'string' ? siteContent.studyDeletedEmailSignoff.trim().slice(0, 200) : '', + studyReminderEmailSubjectPrefix: typeof siteContent.studyReminderEmailSubjectPrefix === 'string' ? siteContent.studyReminderEmailSubjectPrefix.trim().slice(0, 200) : '', + studyReminderEmailBody: typeof siteContent.studyReminderEmailBody === 'string' ? siteContent.studyReminderEmailBody.trim().slice(0, 1000) : '', + studyReminderEmailCtaLabel: typeof siteContent.studyReminderEmailCtaLabel === 'string' ? siteContent.studyReminderEmailCtaLabel.trim().slice(0, 80) : '', + studyReminderEmailSignoff: typeof siteContent.studyReminderEmailSignoff === 'string' ? siteContent.studyReminderEmailSignoff.trim().slice(0, 200) : '', + emailChangeSubject: typeof siteContent.emailChangeSubject === 'string' ? siteContent.emailChangeSubject.trim().slice(0, 200) : '', + emailChangeBody: typeof siteContent.emailChangeBody === 'string' ? siteContent.emailChangeBody.trim().slice(0, 500) : '', + emailChangeCtaLabel: typeof siteContent.emailChangeCtaLabel === 'string' ? siteContent.emailChangeCtaLabel.trim().slice(0, 80) : '', seo: { title: typeof seo.title === 'string' && seo.title.trim() ? seo.title.trim().slice(0, 120) : DEFAULT_SEO.title, description: typeof seo.description === 'string' && seo.description.trim() ? seo.description.trim().slice(0, 240) : DEFAULT_SEO.description, diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index f3570fc..6ffbe37 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -358,6 +358,114 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { ) } +function EmailTemplatesPanel({ + form, + handleChange, + renderSaveStatus, +}: { + form: SiteContent + handleChange: (key: StringField, value: string) => void + renderSaveStatus: () => React.ReactNode +}) { + const [expandedEmail, setExpandedEmail] = useState(null) + + function EmailTemplateCard({ + type, icon, title, trigger, autoVars, fieldKeys, openKey, + }: { + type: 'newsletter' | 'study' | 'transactional' + icon: string + title: string + trigger: string + autoVars?: string + fieldKeys: string[] + openKey: string + }) { + const isOpen = expandedEmail === openKey + return ( +
+ + {isOpen && ( +
+ {autoVars && ( +

+ Auto-inserted: {autoVars} +

+ )} + {fieldKeys.map(key => { + const field = FIELDS.find(f => f.key === key && f.section === 'email-templates') + if (!field) return null + return ( +
+ + {field.multiline + ?