- Add getSemiFinalistStats query with per-category/per-award breakdown - Add sendAccountReminders mutation with invite token generation and dedup - Add SemiFinalistTracker dashboard widget with progress bars and remind buttons - Add ACCOUNT_REMINDER email template - Extend project search to match team member name/email (7 locations) - Fix Passed count deduplication: count distinct projects, not round-state rows - Fix role switcher: visible pills above user section, auto-refresh session on mount Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2518 lines
87 KiB
TypeScript
2518 lines
87 KiB
TypeScript
import nodemailer from 'nodemailer'
|
|
import type { Transporter } from 'nodemailer'
|
|
import { prisma } from '@/lib/prisma'
|
|
|
|
// Cached transporter and config hash to detect changes
|
|
let cachedTransporter: Transporter | null = null
|
|
let cachedConfigHash = ''
|
|
let cachedFrom = ''
|
|
let cachedAt = 0
|
|
const CACHE_TTL = 60_000 // 1 minute
|
|
|
|
/**
|
|
* Get SMTP transporter using database settings with env var fallback.
|
|
* Caches the transporter and rebuilds it when settings change.
|
|
* Uses connection pooling for reliable bulk sends.
|
|
*/
|
|
async function getTransporter(): Promise<{ transporter: Transporter; from: string }> {
|
|
// Fast path: return cached transporter if still fresh
|
|
if (cachedTransporter && Date.now() - cachedAt < CACHE_TTL) {
|
|
return { transporter: cachedTransporter, from: cachedFrom }
|
|
}
|
|
|
|
// Read DB settings
|
|
const dbSettings = await prisma.systemSettings.findMany({
|
|
where: {
|
|
key: { in: ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'email_from_name', 'email_from'] },
|
|
},
|
|
select: { key: true, value: true },
|
|
})
|
|
|
|
const db: Record<string, string> = {}
|
|
for (const s of dbSettings) {
|
|
db[s.key] = s.value
|
|
}
|
|
|
|
// DB settings take priority, env vars as fallback
|
|
const host = db.smtp_host || process.env.SMTP_HOST || 'localhost'
|
|
const port = db.smtp_port || process.env.SMTP_PORT || '587'
|
|
const user = db.smtp_user || process.env.SMTP_USER || ''
|
|
const pass = db.smtp_password || process.env.SMTP_PASS || ''
|
|
|
|
// Combine sender name and email into "Name <email>" format
|
|
const fromName = db.email_from_name || 'MOPC Portal'
|
|
const rawFromEmail = db.email_from || process.env.EMAIL_FROM || 'noreply@monaco-opc.com'
|
|
// Strip "Name <...>" wrapper if present — extract just the email address
|
|
const fromEmail = rawFromEmail.includes('<')
|
|
? rawFromEmail.match(/<([^>]+)>/)?.[1] || rawFromEmail
|
|
: rawFromEmail
|
|
const from = `${fromName} <${fromEmail}>`
|
|
|
|
// Check if config changed since last call
|
|
const configHash = `${host}:${port}:${user}:${pass}:${from}`
|
|
if (cachedTransporter && configHash === cachedConfigHash) {
|
|
cachedAt = Date.now()
|
|
return { transporter: cachedTransporter, from: cachedFrom }
|
|
}
|
|
|
|
// Close old transporter if it exists (clean up pooled connections)
|
|
if (cachedTransporter) {
|
|
try { cachedTransporter.close() } catch { /* ignore */ }
|
|
}
|
|
|
|
// Create new transporter with connection pooling for reliable bulk sends
|
|
cachedTransporter = nodemailer.createTransport({
|
|
host,
|
|
port: parseInt(port),
|
|
secure: port === '465',
|
|
auth: { user, pass },
|
|
pool: true,
|
|
maxConnections: 5,
|
|
maxMessages: 10,
|
|
socketTimeout: 30_000,
|
|
connectionTimeout: 15_000,
|
|
} as nodemailer.TransportOptions)
|
|
cachedConfigHash = configHash
|
|
cachedFrom = from
|
|
cachedAt = Date.now()
|
|
|
|
return { transporter: cachedTransporter, from: cachedFrom }
|
|
}
|
|
|
|
/**
|
|
* Delay helper for throttling bulk email sends.
|
|
* Prevents overwhelming the SMTP server (Poste.io).
|
|
*/
|
|
export function emailDelay(ms = 150): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
// Legacy references for backward compat — default sender from env
|
|
const defaultFrom = process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.com>'
|
|
|
|
// =============================================================================
|
|
// Helpers
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Get the base URL for links in emails.
|
|
* Uses NEXTAUTH_URL with a safe production fallback.
|
|
*/
|
|
export function getBaseUrl(): string {
|
|
return process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
|
}
|
|
|
|
/**
|
|
* Ensure a URL is absolute (has protocol + host).
|
|
* Converts relative paths like "/jury/competitions" to full URLs.
|
|
*/
|
|
export function ensureAbsoluteUrl(url: string | undefined): string | undefined {
|
|
if (!url) return undefined
|
|
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
|
if (url.startsWith('/')) return `${getBaseUrl()}${url}`
|
|
return `${getBaseUrl()}/${url}`
|
|
}
|
|
|
|
// =============================================================================
|
|
// Brand Colors & Logo URLs
|
|
// =============================================================================
|
|
|
|
const BRAND = {
|
|
red: '#de0f1e',
|
|
redHover: '#b91c1c',
|
|
darkBlue: '#053d57',
|
|
teal: '#557f8c',
|
|
white: '#fefefe',
|
|
lightGray: '#f5f5f5',
|
|
textDark: '#1f2937',
|
|
textMuted: '#6b7280',
|
|
}
|
|
|
|
const getSmallLogoUrl = () => 'https://s3.monaco-opc.com/public/MOPC-blue-small.png'
|
|
const getBigLogoUrl = () => 'https://s3.monaco-opc.com/public/MOPC-blue-long.png'
|
|
|
|
// =============================================================================
|
|
// Email Template Wrapper & Helpers
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Wrap email content with consistent branding:
|
|
* - Small logo at top of white content box
|
|
* - Big logo in dark blue footer
|
|
*/
|
|
function getEmailWrapper(content: string): string {
|
|
return `
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
<title>MOPC</title>
|
|
<!--[if mso]>
|
|
<noscript>
|
|
<xml>
|
|
<o:OfficeDocumentSettings>
|
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
</o:OfficeDocumentSettings>
|
|
</xml>
|
|
</noscript>
|
|
<![endif]-->
|
|
</head>
|
|
<body style="margin: 0; padding: 0; background-color: ${BRAND.darkBlue}; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased;">
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-image: url('https://s3.monaco-opc.com/public/ocean.png'); background-size: cover; background-position: center top; background-repeat: no-repeat; background-color: ${BRAND.darkBlue};">
|
|
<tr>
|
|
<td align="center" style="padding: 40px 20px;">
|
|
<!-- Main Container -->
|
|
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; width: 100%;">
|
|
|
|
<!-- Content Box -->
|
|
<tr>
|
|
<td style="background-color: ${BRAND.white}; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);">
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
|
<!-- Small Logo Header -->
|
|
<tr>
|
|
<td align="center" style="padding: 32px 40px 24px 40px;">
|
|
<img src="${getSmallLogoUrl()}" alt="MOPC" width="100" height="auto" style="display: block; border: 0; max-width: 100px;">
|
|
</td>
|
|
</tr>
|
|
<!-- Email Content -->
|
|
<tr>
|
|
<td style="padding: 0 40px 32px 40px;">
|
|
${content}
|
|
</td>
|
|
</tr>
|
|
<!-- Footer -->
|
|
<tr>
|
|
<td style="padding: 0 40px 32px 40px; border-top: 1px solid #e5e7eb;">
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
|
<tr>
|
|
<td align="center" style="padding-top: 24px;">
|
|
<img src="${getBigLogoUrl()}" alt="Monaco Ocean Protection Challenge" width="180" height="auto" style="display: block; border: 0; max-width: 180px; margin-bottom: 12px;">
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td align="center">
|
|
<p style="color: ${BRAND.textMuted}; font-size: 13px; margin: 0; line-height: 1.5;">
|
|
Together for a healthier ocean
|
|
</p>
|
|
<p style="color: ${BRAND.textMuted}; font-size: 12px; margin: 8px 0 0 0;">
|
|
© ${new Date().getFullYear()} Monaco Ocean Protection Challenge
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
`
|
|
}
|
|
|
|
/**
|
|
* Generate a styled CTA button
|
|
*/
|
|
function ctaButton(url: string, text: string): string {
|
|
// Ensure URL is always absolute for email clients
|
|
const safeUrl = ensureAbsoluteUrl(url) || url
|
|
return `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 24px 0;">
|
|
<tr>
|
|
<td align="center">
|
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
|
<tr>
|
|
<td align="center" bgcolor="${BRAND.red}" style="background-color: ${BRAND.red}; border-radius: 8px; mso-border-alt: none;">
|
|
<a href="${safeUrl}" target="_blank" style="display: inline-block; padding: 16px 40px; color: #ffffff; text-decoration: none; font-weight: 600; font-size: 16px; font-family: Helvetica, Arial, sans-serif; mso-line-height-rule: exactly; line-height: 20px;">
|
|
${text}
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td align="center" style="padding-top: 8px;">
|
|
<p style="margin: 0; font-size: 12px; color: ${BRAND.textMuted};">
|
|
If the button doesn't work: <a href="${safeUrl}" target="_blank" style="color: ${BRAND.teal}; word-break: break-all;">${safeUrl}</a>
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
}
|
|
|
|
/**
|
|
* Generate styled section title
|
|
*/
|
|
function sectionTitle(text: string): string {
|
|
return `<h2 style="color: ${BRAND.darkBlue}; margin: 0 0 16px 0; font-size: 22px; font-weight: 600; line-height: 1.3;">${text}</h2>`
|
|
}
|
|
|
|
/**
|
|
* Generate styled paragraph
|
|
*/
|
|
function paragraph(text: string): string {
|
|
return `<p style="color: ${BRAND.textDark}; margin: 0 0 16px 0; font-size: 15px; line-height: 1.6;">${text}</p>`
|
|
}
|
|
|
|
/**
|
|
* Generate styled info box
|
|
*/
|
|
function infoBox(content: string, variant: 'warning' | 'info' | 'success' = 'info'): string {
|
|
const colors = {
|
|
warning: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
|
|
info: { bg: '#e0f2fe', border: '#0ea5e9', text: '#0369a1' },
|
|
success: { bg: '#dcfce7', border: '#22c55e', text: '#166534' },
|
|
}
|
|
const c = colors[variant]
|
|
return `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: ${c.bg}; border-left: 4px solid ${c.border}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
|
<p style="color: ${c.text}; margin: 0; font-size: 14px; font-weight: 500;">${content}</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
}
|
|
|
|
/**
|
|
* Generate styled stat card
|
|
*/
|
|
function statCard(label: string, value: string | number): string {
|
|
return `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px; text-align: center;">
|
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">${label}</p>
|
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 42px; font-weight: 700; line-height: 1;">${value}</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
}
|
|
|
|
// =============================================================================
|
|
// Email Templates
|
|
// =============================================================================
|
|
|
|
interface EmailTemplate {
|
|
subject: string
|
|
html: string
|
|
text: string
|
|
}
|
|
|
|
/**
|
|
* Generate magic link email template
|
|
*/
|
|
function getMagicLinkTemplate(url: string, expiryMinutes: number = 15): EmailTemplate {
|
|
const content = `
|
|
${sectionTitle('Sign in to your account')}
|
|
${paragraph('Click the button below to securely sign in to the MOPC Portal.')}
|
|
${infoBox(`<strong>This link expires in ${expiryMinutes} minutes</strong>`, 'warning')}
|
|
${ctaButton(url, 'Sign In to MOPC')}
|
|
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
|
|
If you didn't request this email, you can safely ignore it.
|
|
</p>
|
|
`
|
|
|
|
return {
|
|
subject: 'Sign in to MOPC Portal',
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
Sign in to MOPC Portal
|
|
=========================
|
|
|
|
Click the link below to sign in to your account:
|
|
|
|
${url}
|
|
|
|
This link will expire in ${expiryMinutes} minutes.
|
|
|
|
If you didn't request this email, you can safely ignore it.
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate generic invitation email template (not round-specific)
|
|
*/
|
|
function formatExpiryLabel(hours: number): string {
|
|
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''}`
|
|
const days = Math.round(hours / 24)
|
|
return `${days} day${days !== 1 ? 's' : ''}`
|
|
}
|
|
|
|
function getGenericInvitationTemplate(
|
|
name: string,
|
|
url: string,
|
|
role: string,
|
|
expiryHours: number = 72
|
|
): EmailTemplate {
|
|
const roleLabel = role === 'JURY_MEMBER' ? 'jury member' : role.toLowerCase().replace('_', ' ')
|
|
const article = /^[aeiou]/i.test(roleLabel) ? 'an' : 'a'
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
const expiryLabel = formatExpiryLabel(expiryHours)
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${paragraph(`You've been invited to join the Monaco Ocean Protection Challenge platform as ${article} <strong>${roleLabel}</strong>.`)}
|
|
${paragraph('Click the button below to set up your account and get started.')}
|
|
${ctaButton(url, 'Accept Invitation')}
|
|
${infoBox(`This link will expire in ${expiryLabel}.`, 'info')}
|
|
`
|
|
|
|
return {
|
|
subject: "You're invited to join the MOPC Portal",
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
You've been invited to join the Monaco Ocean Protection Challenge platform as ${article} ${roleLabel}.
|
|
|
|
Click the link below to set up your account and get started:
|
|
|
|
${url}
|
|
|
|
This link will expire in ${expiryLabel}.
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate evaluation reminder email template
|
|
*/
|
|
function getEvaluationReminderTemplate(
|
|
name: string,
|
|
pendingCount: number,
|
|
roundName: string,
|
|
deadline: string,
|
|
assignmentsUrl: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
|
|
// Deadline alert box (styled differently from info box)
|
|
const deadlineBox = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
|
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${paragraph(`This is a friendly reminder about your pending evaluations for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
|
${statCard('Pending Evaluations', pendingCount)}
|
|
${deadlineBox}
|
|
${paragraph('Your expert evaluation helps identify the most promising ocean conservation projects. Please complete your reviews before the deadline.')}
|
|
${ctaButton(assignmentsUrl, 'View My Assignments')}
|
|
`
|
|
|
|
return {
|
|
subject: `Reminder: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} awaiting your review`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
This is a friendly reminder that you have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''} for ${roundName}.
|
|
|
|
Deadline: ${deadline}
|
|
|
|
Please complete your evaluations before the deadline to ensure your feedback is included in the selection process.
|
|
|
|
View your assignments: ${assignmentsUrl}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate announcement email template
|
|
*/
|
|
function getAnnouncementTemplate(
|
|
name: string,
|
|
title: string,
|
|
message: string,
|
|
ctaText?: string,
|
|
ctaUrl?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
const ctaTextPlain = ctaText && ctaUrl ? `\n${ctaText}: ${ctaUrl}\n` : ''
|
|
|
|
// Escape HTML in message but preserve line breaks
|
|
const formattedMessage = message
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\n/g, '<br>')
|
|
|
|
// Title card with success styling
|
|
const titleCard = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: #ecfdf5; border-left: 4px solid #059669; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
|
<h3 style="color: #065f46; margin: 0; font-size: 18px; font-weight: 700;">${title}</h3>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${titleCard}
|
|
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">
|
|
${formattedMessage}
|
|
</div>
|
|
${ctaText && ctaUrl ? ctaButton(ctaUrl, ctaText) : ''}
|
|
`
|
|
|
|
return {
|
|
subject: title,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
${title}
|
|
|
|
${message}
|
|
${ctaTextPlain}
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate jury invitation email template
|
|
*/
|
|
function getJuryInvitationTemplate(
|
|
name: string,
|
|
url: string,
|
|
roundName: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${paragraph(`You've been invited to serve as a jury member for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
|
${paragraph('As a jury member, you\'ll evaluate innovative ocean protection projects and help select the most promising initiatives.')}
|
|
${ctaButton(url, 'Accept Invitation')}
|
|
<p style="color: ${BRAND.textMuted}; margin: 20px 0 0 0; font-size: 13px; text-align: center;">
|
|
This link will allow you to access the platform and view your assigned projects.
|
|
</p>
|
|
`
|
|
|
|
return {
|
|
subject: `You're invited to evaluate projects for ${roundName}`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
You've been invited to serve as a jury member for ${roundName}.
|
|
|
|
As a jury member, you'll evaluate innovative ocean protection projects and help select the most promising initiatives.
|
|
|
|
Click the link below to accept your invitation:
|
|
|
|
${url}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate application confirmation email template
|
|
*/
|
|
function getApplicationConfirmationTemplate(
|
|
name: string,
|
|
projectName: string,
|
|
programName: string,
|
|
customMessage?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
|
|
const customMessageHtml = customMessage
|
|
? `<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0; padding: 20px; background-color: ${BRAND.lightGray}; border-radius: 8px;">${customMessage.replace(/\n/g, '<br>')}</div>`
|
|
: ''
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${paragraph(`Thank you for submitting your application to <strong style="color: ${BRAND.darkBlue};">${programName}</strong>!`)}
|
|
${infoBox(`Your project "<strong>${projectName}</strong>" has been successfully received.`, 'success')}
|
|
${customMessageHtml}
|
|
${paragraph('Our team will review your submission and get back to you soon. In the meantime, if you have any questions, please don\'t hesitate to reach out.')}
|
|
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
|
|
You will receive email updates about your application status.
|
|
</p>
|
|
`
|
|
|
|
return {
|
|
subject: `Application Received - ${projectName}`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
Thank you for submitting your application to ${programName}!
|
|
|
|
Your project "${projectName}" has been successfully received.
|
|
|
|
${customMessage || ''}
|
|
|
|
Our team will review your submission and get back to you soon. In the meantime, if you have any questions, please don't hesitate to reach out.
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate team member invite email template
|
|
*/
|
|
function getTeamMemberInviteTemplate(
|
|
name: string,
|
|
projectName: string,
|
|
teamLeadName: string,
|
|
inviteUrl: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${paragraph(`<strong>${teamLeadName}</strong> has invited you to join their team for the project "<strong style="color: ${BRAND.darkBlue};">${projectName}</strong>" on the Monaco Ocean Protection Challenge platform.`)}
|
|
${paragraph('Click the button below to accept the invitation and set up your account.')}
|
|
${ctaButton(inviteUrl, 'Accept Invitation')}
|
|
${infoBox('This invitation link will expire in 30 days.', 'info')}
|
|
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
|
|
If you weren't expecting this invitation, you can safely ignore this email.
|
|
</p>
|
|
`
|
|
|
|
return {
|
|
subject: `You've been invited to join "${projectName}"`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
${teamLeadName} has invited you to join their team for the project "${projectName}" on the Monaco Ocean Protection Challenge platform.
|
|
|
|
Click the link below to accept the invitation and set up your account:
|
|
|
|
${inviteUrl}
|
|
|
|
This invitation link will expire in 30 days.
|
|
|
|
If you weren't expecting this invitation, you can safely ignore this email.
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Notification Email Templates
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Context passed to notification email templates
|
|
*/
|
|
export interface NotificationEmailContext {
|
|
name?: string
|
|
title: string
|
|
message: string
|
|
linkUrl?: string
|
|
linkLabel?: string
|
|
metadata?: Record<string, unknown>
|
|
}
|
|
|
|
/**
|
|
* Generate "Advanced to Semi-Finals" email template
|
|
*/
|
|
function getAdvancedSemifinalTemplate(
|
|
name: string,
|
|
projectName: string,
|
|
programName: string,
|
|
nextSteps?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
|
|
|
const celebrationBanner = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background: linear-gradient(135deg, #059669 0%, #0d9488 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
|
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Exciting News</p>
|
|
<h2 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">You're a Semi-Finalist!</h2>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${celebrationBanner}
|
|
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has been selected to advance to the semi-finals of ${programName}.`)}
|
|
${infoBox('Your innovative approach to ocean protection stood out among hundreds of submissions.', 'success')}
|
|
${nextSteps ? paragraph(`<strong>Next Steps:</strong> ${nextSteps}`) : paragraph('Our team will be in touch shortly with details about the next phase of the competition.')}
|
|
`
|
|
|
|
return {
|
|
subject: `Congratulations! "${projectName}" advances to Semi-Finals`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
Your project "${projectName}" has been selected to advance to the semi-finals of ${programName}.
|
|
|
|
Your innovative approach to ocean protection stood out among hundreds of submissions.
|
|
|
|
${nextSteps || 'Our team will be in touch shortly with details about the next phase of the competition.'}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "Selected as Finalist" email template
|
|
*/
|
|
function getAdvancedFinalTemplate(
|
|
name: string,
|
|
projectName: string,
|
|
programName: string,
|
|
nextSteps?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Incredible news, ${name}!` : 'Incredible news!'
|
|
|
|
const celebrationBanner = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background: linear-gradient(135deg, ${BRAND.darkBlue} 0%, #0891b2 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
|
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Outstanding Achievement</p>
|
|
<h2 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">You're a Finalist!</h2>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${celebrationBanner}
|
|
${paragraph(`Your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has been selected as a <strong>Finalist</strong> in ${programName}.`)}
|
|
${infoBox('You are now among the top projects competing for the grand prize!', 'success')}
|
|
${nextSteps ? paragraph(`<strong>What Happens Next:</strong> ${nextSteps}`) : paragraph('Prepare for the final stage of the competition. Our team will contact you with details about the finalist presentations and judging.')}
|
|
`
|
|
|
|
return {
|
|
subject: `You're a Finalist! "${projectName}" selected for finals`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
Your project "${projectName}" has been selected as a Finalist in ${programName}.
|
|
|
|
You are now among the top projects competing for the grand prize!
|
|
|
|
${nextSteps || 'Prepare for the final stage of the competition. Our team will contact you with details about the finalist presentations and judging.'}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "Mentor Assigned" email template (for team)
|
|
*/
|
|
function getMentorAssignedTemplate(
|
|
name: string,
|
|
projectName: string,
|
|
mentorName: string,
|
|
mentorBio?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
|
|
const mentorCard = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px;">
|
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Your Mentor</p>
|
|
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${mentorName}</p>
|
|
${mentorBio ? `<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px; line-height: 1.5;">${mentorBio}</p>` : ''}
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${paragraph(`Great news! A mentor has been assigned to support your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong>.`)}
|
|
${mentorCard}
|
|
${paragraph('Your mentor will provide guidance, feedback, and support as you develop your ocean protection initiative. They will reach out to you shortly to introduce themselves and schedule your first meeting.')}
|
|
${infoBox('Mentorship is a valuable opportunity - make the most of their expertise!', 'info')}
|
|
`
|
|
|
|
return {
|
|
subject: `A mentor has been assigned to "${projectName}"`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
Great news! A mentor has been assigned to support your project "${projectName}".
|
|
|
|
Your Mentor: ${mentorName}
|
|
${mentorBio || ''}
|
|
|
|
Your mentor will provide guidance, feedback, and support as you develop your ocean protection initiative. They will reach out to you shortly to introduce themselves and schedule your first meeting.
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "Not Selected" email template
|
|
*/
|
|
function getNotSelectedTemplate(
|
|
name: string,
|
|
projectName: string,
|
|
roundName: string,
|
|
feedbackUrl?: string,
|
|
encouragement?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${paragraph(`Thank you for participating in ${roundName} with your project <strong>"${projectName}"</strong>.`)}
|
|
${paragraph('After careful consideration by our jury, we regret to inform you that your project was not selected to advance to the next round.')}
|
|
${infoBox('This decision was incredibly difficult given the high quality of submissions we received this year.', 'info')}
|
|
${feedbackUrl ? ctaButton(feedbackUrl, 'View Jury Feedback') : ''}
|
|
${paragraph(encouragement || 'We encourage you to continue developing your ocean protection initiative and to apply again in future editions. Your commitment to protecting our oceans is valuable and appreciated.')}
|
|
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 14px; text-align: center;">
|
|
Thank you for being part of the Monaco Ocean Protection Challenge community.
|
|
</p>
|
|
`
|
|
|
|
return {
|
|
subject: `Update on your application: "${projectName}"`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
Thank you for participating in ${roundName} with your project "${projectName}".
|
|
|
|
After careful consideration by our jury, we regret to inform you that your project was not selected to advance to the next round.
|
|
|
|
This decision was incredibly difficult given the high quality of submissions we received this year.
|
|
|
|
${feedbackUrl ? `View jury feedback: ${feedbackUrl}` : ''}
|
|
|
|
${encouragement || 'We encourage you to continue developing your ocean protection initiative and to apply again in future editions.'}
|
|
|
|
Thank you for being part of the Monaco Ocean Protection Challenge community.
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "Winner Announcement" email template
|
|
*/
|
|
function getWinnerAnnouncementTemplate(
|
|
name: string,
|
|
projectName: string,
|
|
awardName: string,
|
|
prizeDetails?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
|
|
|
const trophyBanner = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background: linear-gradient(135deg, #f59e0b 0%, #eab308 100%); border-radius: 12px; padding: 32px; text-align: center;">
|
|
<p style="color: #ffffff; font-size: 48px; margin: 0 0 12px 0;">🏆</p>
|
|
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Winner</p>
|
|
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${awardName}</h2>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${trophyBanner}
|
|
${paragraph(`We are thrilled to announce that your project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has been selected as the winner of the <strong>${awardName}</strong>!`)}
|
|
${infoBox('Your outstanding work in ocean protection has made a lasting impression on our jury.', 'success')}
|
|
${prizeDetails ? paragraph(`<strong>Your Prize:</strong> ${prizeDetails}`) : ''}
|
|
${paragraph('Our team will be in touch shortly with details about the award ceremony and next steps.')}
|
|
`
|
|
|
|
return {
|
|
subject: `You Won! "${projectName}" wins ${awardName}`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
We are thrilled to announce that your project "${projectName}" has been selected as the winner of the ${awardName}!
|
|
|
|
Your outstanding work in ocean protection has made a lasting impression on our jury.
|
|
|
|
${prizeDetails ? `Your Prize: ${prizeDetails}` : ''}
|
|
|
|
Our team will be in touch shortly with details about the award ceremony and next steps.
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "Assigned to Project" email template (for jury)
|
|
*/
|
|
function getAssignedToProjectTemplate(
|
|
name: string,
|
|
projectName: string,
|
|
roundName: string,
|
|
deadline?: string,
|
|
assignmentsUrl?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
|
|
const projectCard = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: ${BRAND.lightGray}; border-left: 4px solid ${BRAND.teal}; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">New Assignment</p>
|
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;">${projectName}</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const deadlineBox = deadline ? `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
|
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
` : ''
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${paragraph(`You have been assigned a new project to evaluate for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
|
${projectCard}
|
|
${deadlineBox}
|
|
${paragraph('Please review the project materials and submit your evaluation before the deadline.')}
|
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignment') : ''}
|
|
`
|
|
|
|
return {
|
|
subject: `New Assignment: "${projectName}" - ${roundName}`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
You have been assigned a new project to evaluate for ${roundName}.
|
|
|
|
Project: ${projectName}
|
|
${deadline ? `Deadline: ${deadline}` : ''}
|
|
|
|
Please review the project materials and submit your evaluation before the deadline.
|
|
|
|
${assignmentsUrl ? `View assignment: ${assignmentsUrl}` : ''}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "COI Reassignment" email template (for jury receiving a reassigned project)
|
|
*/
|
|
function getCOIReassignedTemplate(
|
|
name: string,
|
|
projectName: string,
|
|
roundName: string,
|
|
deadline?: string,
|
|
assignmentsUrl?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
|
|
const projectCard = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Reassigned Project</p>
|
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 18px; font-weight: 700;">${projectName}</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const deadlineBox = deadline ? `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
|
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
` : ''
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${paragraph(`A project has been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>, because the previously assigned juror declared a conflict of interest.`)}
|
|
${projectCard}
|
|
${deadlineBox}
|
|
${paragraph('Please review the project materials and submit your evaluation before the deadline. This is an additional project on top of your existing assignments.')}
|
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignment') : ''}
|
|
`
|
|
|
|
return {
|
|
subject: `Project Reassigned to You: "${projectName}" - ${roundName}`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
A project has been reassigned to you for evaluation in ${roundName}, because the previously assigned juror declared a conflict of interest.
|
|
|
|
Project: ${projectName}
|
|
${deadline ? `Deadline: ${deadline}` : ''}
|
|
|
|
Please review the project materials and submit your evaluation before the deadline. This is an additional project on top of your existing assignments.
|
|
|
|
${assignmentsUrl ? `View assignment: ${assignmentsUrl}` : ''}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "Manual Reassignment" email template (for jury)
|
|
* Sent when an admin manually transfers a project assignment to a juror.
|
|
*/
|
|
function getManualReassignedTemplate(
|
|
name: string,
|
|
projectNames: string[],
|
|
roundName: string,
|
|
deadline?: string,
|
|
assignmentsUrl?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
const count = projectNames.length
|
|
const isSingle = count === 1
|
|
|
|
const projectList = projectNames.map((p) => `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 8px 0;">
|
|
<tr>
|
|
<td style="background-color: #eff6ff; border-left: 4px solid ${BRAND.darkBlue}; border-radius: 0 8px 8px 0; padding: 14px 20px;">
|
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${p}</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`).join('')
|
|
|
|
const deadlineBox = deadline ? `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
|
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
` : ''
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${paragraph(`An administrator has <strong>reassigned ${isSingle ? 'a project' : `${count} projects`}</strong> to you for evaluation in <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
|
${projectList}
|
|
${deadlineBox}
|
|
${paragraph(`Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.`)}
|
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignments') : ''}
|
|
`
|
|
|
|
const projectListText = projectNames.map((p) => ` - ${p}`).join('\n')
|
|
|
|
return {
|
|
subject: `Project${isSingle ? '' : 's'} Reassigned to You - ${roundName}`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
An administrator has reassigned ${isSingle ? 'a project' : `${count} projects`} to you for evaluation in ${roundName}.
|
|
|
|
${isSingle ? `Project: ${projectNames[0]}` : `Projects:\n${projectListText}`}
|
|
${deadline ? `Deadline: ${deadline}` : ''}
|
|
|
|
Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.
|
|
|
|
${assignmentsUrl ? `View assignments: ${assignmentsUrl}` : ''}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "Dropout Reassignment" email template (for jury)
|
|
* Sent when a juror drops out and their projects are redistributed.
|
|
*/
|
|
function getDropoutReassignedTemplate(
|
|
name: string,
|
|
projectNames: string[],
|
|
roundName: string,
|
|
droppedJurorName: string,
|
|
deadline?: string,
|
|
assignmentsUrl?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
const count = projectNames.length
|
|
const isSingle = count === 1
|
|
|
|
const projectList = projectNames.map((p) => `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 8px 0;">
|
|
<tr>
|
|
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 14px 20px;">
|
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${p}</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`).join('')
|
|
|
|
const deadlineBox = deadline ? `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
|
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
` : ''
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${paragraph(`Due to a juror becoming unavailable, ${isSingle ? 'a project has' : `${count} projects have`} been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
|
${projectList}
|
|
${deadlineBox}
|
|
${paragraph(`${isSingle ? 'This project was' : 'These projects were'} previously assigned to ${droppedJurorName}, who is no longer available. Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.`)}
|
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignments') : ''}
|
|
`
|
|
|
|
const projectListText = projectNames.map((p) => ` - ${p}`).join('\n')
|
|
|
|
return {
|
|
subject: `Project${isSingle ? '' : 's'} Reassigned to You (Juror Unavailable) - ${roundName}`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
Due to a juror becoming unavailable, ${isSingle ? 'a project has' : `${count} projects have`} been reassigned to you for evaluation in ${roundName}.
|
|
|
|
${isSingle ? `Project: ${projectNames[0]}` : `Projects:\n${projectListText}`}
|
|
${deadline ? `Deadline: ${deadline}` : ''}
|
|
|
|
${isSingle ? 'This project was' : 'These projects were'} previously assigned to ${droppedJurorName}, who is no longer available. Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.
|
|
|
|
${assignmentsUrl ? `View assignments: ${assignmentsUrl}` : ''}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "Batch Assigned" email template (for jury)
|
|
*/
|
|
function getBatchAssignedTemplate(
|
|
name: string,
|
|
projectCount: number,
|
|
roundName: string,
|
|
deadline?: string,
|
|
assignmentsUrl?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
|
|
const deadlineBox = deadline ? `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
|
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
` : ''
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${paragraph(`You have been assigned projects to evaluate for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
|
${statCard('Projects Assigned', projectCount)}
|
|
${deadlineBox}
|
|
${paragraph('Please review each project and submit your evaluations before the deadline. Your expert assessment is crucial to identifying the most promising ocean protection initiatives.')}
|
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View All Assignments') : ''}
|
|
`
|
|
|
|
return {
|
|
subject: `${projectCount} Projects Assigned - ${roundName}`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
You have been assigned ${projectCount} project${projectCount !== 1 ? 's' : ''} to evaluate for ${roundName}.
|
|
|
|
${deadline ? `Deadline: ${deadline}` : ''}
|
|
|
|
Please review each project and submit your evaluations before the deadline.
|
|
|
|
${assignmentsUrl ? `View assignments: ${assignmentsUrl}` : ''}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "Round Now Open" email template (for jury)
|
|
*/
|
|
function getRoundNowOpenTemplate(
|
|
name: string,
|
|
roundName: string,
|
|
projectCount: number,
|
|
deadline?: string,
|
|
assignmentsUrl?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
|
|
const openBanner = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background: linear-gradient(135deg, ${BRAND.teal} 0%, #0891b2 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
|
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Evaluation Round</p>
|
|
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">${roundName} is Now Open</h2>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const deadlineBox = deadline ? `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
|
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
` : ''
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${openBanner}
|
|
${statCard('Projects to Evaluate', projectCount)}
|
|
${deadlineBox}
|
|
${paragraph('The evaluation round is now open. Please log in to the MOPC Portal to begin reviewing your assigned projects.')}
|
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Start Evaluating') : ''}
|
|
`
|
|
|
|
return {
|
|
subject: `${roundName} is Now Open - ${projectCount} Projects Await`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
${roundName} is now open for evaluation.
|
|
|
|
You have ${projectCount} project${projectCount !== 1 ? 's' : ''} to evaluate.
|
|
${deadline ? `Deadline: ${deadline}` : ''}
|
|
|
|
Please log in to the MOPC Portal to begin reviewing your assigned projects.
|
|
|
|
${assignmentsUrl ? `Start evaluating: ${assignmentsUrl}` : ''}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "24 Hour Reminder" email template (for jury)
|
|
*/
|
|
function getReminder24HTemplate(
|
|
name: string,
|
|
pendingCount: number,
|
|
roundName: string,
|
|
deadline: string,
|
|
assignmentsUrl?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
|
|
const urgentBox = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
|
<p style="color: #92400e; margin: 0; font-size: 14px; font-weight: 600;">⚠ 24 Hours Remaining</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${urgentBox}
|
|
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${roundName}</strong> closes in 24 hours.`)}
|
|
${statCard('Pending Evaluations', pendingCount)}
|
|
${infoBox(`<strong>Deadline:</strong> ${deadline}`, 'warning')}
|
|
${paragraph('Please complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
|
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
|
|
`
|
|
|
|
return {
|
|
subject: `Reminder: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} due in 24 hours`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
This is a reminder that ${roundName} closes in 24 hours.
|
|
|
|
You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''}.
|
|
Deadline: ${deadline}
|
|
|
|
Please complete your remaining evaluations before the deadline.
|
|
|
|
${assignmentsUrl ? `Complete evaluations: ${assignmentsUrl}` : ''}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "3 Days Remaining" email template (for jury)
|
|
*/
|
|
function getReminder3DaysTemplate(
|
|
name: string,
|
|
pendingCount: number,
|
|
roundName: string,
|
|
deadline: string,
|
|
assignmentsUrl?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
|
|
const urgentBox = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
|
<p style="color: #92400e; margin: 0; font-size: 14px; font-weight: 600;">⚠ 3 Days Remaining</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${urgentBox}
|
|
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${roundName}</strong> closes in 3 days.`)}
|
|
${statCard('Pending Evaluations', pendingCount)}
|
|
${infoBox(`<strong>Deadline:</strong> ${deadline}`, 'warning')}
|
|
${paragraph('Please plan to complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
|
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
|
|
`
|
|
|
|
return {
|
|
subject: `Reminder: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} due in 3 days`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
This is a reminder that ${roundName} closes in 3 days.
|
|
|
|
You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''}.
|
|
Deadline: ${deadline}
|
|
|
|
Please plan to complete your remaining evaluations before the deadline.
|
|
|
|
${assignmentsUrl ? `Complete evaluations: ${assignmentsUrl}` : ''}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "1 Hour Reminder" email template (for jury)
|
|
*/
|
|
function getReminder1HTemplate(
|
|
name: string,
|
|
pendingCount: number,
|
|
roundName: string,
|
|
deadline: string,
|
|
assignmentsUrl?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `${name},` : 'Attention,'
|
|
|
|
const urgentBanner = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background: linear-gradient(135deg, ${BRAND.red} 0%, #dc2626 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
|
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Urgent</p>
|
|
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">1 Hour Remaining</h2>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${urgentBanner}
|
|
${paragraph(`<strong style="color: ${BRAND.red};">${roundName}</strong> closes in <strong>1 hour</strong>.`)}
|
|
${statCard('Evaluations Still Pending', pendingCount)}
|
|
${paragraph('Please submit your remaining evaluations immediately to ensure they are counted.')}
|
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Submit Now') : ''}
|
|
`
|
|
|
|
return {
|
|
subject: `URGENT: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} due in 1 hour`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
URGENT
|
|
|
|
${roundName} closes in 1 hour!
|
|
|
|
You have ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} still pending.
|
|
|
|
Please submit your remaining evaluations immediately.
|
|
|
|
${assignmentsUrl ? `Submit now: ${assignmentsUrl}` : ''}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "Award Voting Open" email template (for jury)
|
|
*/
|
|
function getAwardVotingOpenTemplate(
|
|
name: string,
|
|
awardName: string,
|
|
finalistCount: number,
|
|
deadline?: string,
|
|
votingUrl?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
|
|
const awardBanner = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
|
<p style="color: #ffffff; font-size: 36px; margin: 0 0 8px 0;">🏆</p>
|
|
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Special Award</p>
|
|
<h2 style="color: #ffffff; margin: 0; font-size: 22px; font-weight: 700;">${awardName}</h2>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${awardBanner}
|
|
${paragraph(`Voting is now open for the <strong style="color: ${BRAND.darkBlue};">${awardName}</strong>.`)}
|
|
${statCard('Finalists', finalistCount)}
|
|
${deadline ? infoBox(`<strong>Voting closes:</strong> ${deadline}`, 'warning') : ''}
|
|
${paragraph('Please review the finalist projects and cast your vote for the most deserving recipient.')}
|
|
${votingUrl ? ctaButton(votingUrl, 'Cast Your Vote') : ''}
|
|
`
|
|
|
|
return {
|
|
subject: `Vote Now: ${awardName}`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
Voting is now open for the ${awardName}.
|
|
|
|
${finalistCount} finalists are competing for this award.
|
|
${deadline ? `Voting closes: ${deadline}` : ''}
|
|
|
|
Please review the finalist projects and cast your vote.
|
|
|
|
${votingUrl ? `Cast your vote: ${votingUrl}` : ''}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "Mentee Assigned" email template (for mentor)
|
|
*/
|
|
function getMenteeAssignedTemplate(
|
|
name: string,
|
|
projectName: string,
|
|
teamLeadName: string,
|
|
teamLeadEmail?: string,
|
|
projectUrl?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
|
|
const projectCard = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px;">
|
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Your New Mentee</p>
|
|
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 20px; font-weight: 700;">${projectName}</p>
|
|
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
|
|
<strong>Team Lead:</strong> ${teamLeadName}${teamLeadEmail ? ` (${teamLeadEmail})` : ''}
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${paragraph('You have been assigned as a mentor to a new project in the Monaco Ocean Protection Challenge.')}
|
|
${projectCard}
|
|
${paragraph('As a mentor, you play a crucial role in guiding this team toward success. Please reach out to introduce yourself and schedule your first meeting.')}
|
|
${infoBox('Your expertise and guidance can make a significant impact on their ocean protection initiative.', 'info')}
|
|
${projectUrl ? ctaButton(projectUrl, 'View Project Details') : ''}
|
|
`
|
|
|
|
return {
|
|
subject: `New Mentee Assignment: "${projectName}"`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
You have been assigned as a mentor to a new project in the Monaco Ocean Protection Challenge.
|
|
|
|
Project: ${projectName}
|
|
Team Lead: ${teamLeadName}${teamLeadEmail ? ` (${teamLeadEmail})` : ''}
|
|
|
|
Please reach out to introduce yourself and schedule your first meeting.
|
|
|
|
${projectUrl ? `View project: ${projectUrl}` : ''}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "Mentee Advanced" email template (for mentor)
|
|
*/
|
|
function getMenteeAdvancedTemplate(
|
|
name: string,
|
|
projectName: string,
|
|
roundName: string,
|
|
nextRoundName?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${infoBox('Great news about your mentee!', 'success')}
|
|
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has advanced to the next stage!`)}
|
|
${statCard('Advanced From', roundName)}
|
|
${nextRoundName ? paragraph(`They will now compete in <strong>${nextRoundName}</strong>.`) : ''}
|
|
${paragraph('Your guidance is making a difference. Continue supporting the team as they progress in the competition.')}
|
|
`
|
|
|
|
return {
|
|
subject: `Your Mentee Advanced: "${projectName}"`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
Great news! Your mentee project "${projectName}" has advanced to the next stage.
|
|
|
|
Advanced from: ${roundName}
|
|
${nextRoundName ? `Now competing in: ${nextRoundName}` : ''}
|
|
|
|
Your guidance is making a difference. Continue supporting the team as they progress.
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "Mentee Won" email template (for mentor)
|
|
*/
|
|
function getMenteeWonTemplate(
|
|
name: string,
|
|
projectName: string,
|
|
awardName: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
|
|
|
const trophyBanner = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background: linear-gradient(135deg, #f59e0b 0%, #eab308 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
|
<p style="color: #ffffff; font-size: 48px; margin: 0 0 12px 0;">🏆</p>
|
|
<p style="color: #ffffff; margin: 0; font-size: 16px; font-weight: 600;">Your Mentee Won!</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${trophyBanner}
|
|
${paragraph(`Your mentee project <strong style="color: ${BRAND.darkBlue};">"${projectName}"</strong> has won the <strong>${awardName}</strong>!`)}
|
|
${infoBox('Your mentorship played a vital role in their success.', 'success')}
|
|
${paragraph('Thank you for your dedication and support. The impact of your guidance extends beyond this competition.')}
|
|
`
|
|
|
|
return {
|
|
subject: `Your Mentee Won: "${projectName}" - ${awardName}`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
Your mentee project "${projectName}" has won the ${awardName}!
|
|
|
|
Your mentorship played a vital role in their success.
|
|
|
|
Thank you for your dedication and support.
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "New Application" email template (for admins)
|
|
*/
|
|
function getNewApplicationTemplate(
|
|
projectName: string,
|
|
applicantName: string,
|
|
applicantEmail: string,
|
|
programName: string,
|
|
reviewUrl?: string
|
|
): EmailTemplate {
|
|
const applicationCard = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background-color: ${BRAND.lightGray}; border-left: 4px solid ${BRAND.teal}; border-radius: 0 12px 12px 0; padding: 20px 24px;">
|
|
<p style="color: ${BRAND.textMuted}; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">New Application</p>
|
|
<p style="color: ${BRAND.darkBlue}; margin: 0 0 12px 0; font-size: 18px; font-weight: 700;">${projectName}</p>
|
|
<p style="color: ${BRAND.textDark}; margin: 0; font-size: 14px;">
|
|
<strong>Applicant:</strong> ${applicantName} (${applicantEmail})
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const content = `
|
|
${sectionTitle('New Application Received')}
|
|
${paragraph(`A new application has been submitted to <strong style="color: ${BRAND.darkBlue};">${programName}</strong>.`)}
|
|
${applicationCard}
|
|
${reviewUrl ? ctaButton(reviewUrl, 'Review Application') : ''}
|
|
`
|
|
|
|
return {
|
|
subject: `New Application: "${projectName}"`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
New Application Received
|
|
|
|
A new application has been submitted to ${programName}.
|
|
|
|
Project: ${projectName}
|
|
Applicant: ${applicantName} (${applicantEmail})
|
|
|
|
${reviewUrl ? `Review application: ${reviewUrl}` : ''}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "Project Advanced" notification email template
|
|
*/
|
|
export function getAdvancementNotificationTemplate(
|
|
name: string,
|
|
projectName: string,
|
|
fromRoundName: string,
|
|
toRoundName: string,
|
|
customMessage?: string,
|
|
accountUrl?: string,
|
|
fullCustomBody?: boolean,
|
|
): EmailTemplate {
|
|
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
|
|
|
const escapedMessage = customMessage
|
|
? customMessage
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\n/g, '<br>')
|
|
: null
|
|
|
|
// Full custom body mode: only the custom message inside the branded wrapper
|
|
if (fullCustomBody && escapedMessage) {
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">${escapedMessage}</div>
|
|
${accountUrl
|
|
? ctaButton(accountUrl, 'Create Your Account')
|
|
: ctaButton('/applicant', 'View Your Dashboard')}
|
|
`
|
|
return {
|
|
subject: `Your project has advanced: "${projectName}"`,
|
|
html: getEmailWrapper(content),
|
|
text: `${greeting}\n\n${customMessage}\n\n${accountUrl ? `Create your account: ${getBaseUrl()}${accountUrl}` : `Visit your dashboard: ${getBaseUrl()}/applicant`}\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
|
}
|
|
}
|
|
|
|
const celebrationBanner = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background: linear-gradient(135deg, #059669 0%, #0d9488 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
|
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Great News</p>
|
|
<h2 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">Your project has advanced!</h2>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${celebrationBanner}
|
|
${infoBox(`<strong>"${projectName}"</strong>`, 'success')}
|
|
${infoBox(`Advanced from <strong>${fromRoundName}</strong> to <strong>${toRoundName}</strong>`, 'info')}
|
|
${
|
|
escapedMessage
|
|
? `<div style="background-color: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7;">${escapedMessage}</div>`
|
|
: paragraph('Our team will be in touch with more details about the next phase.')
|
|
}
|
|
${accountUrl
|
|
? ctaButton(accountUrl, 'Create Your Account')
|
|
: ctaButton('/applicant', 'View Your Dashboard')}
|
|
`
|
|
|
|
return {
|
|
subject: `Your project has advanced: "${projectName}"`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
Your project has advanced!
|
|
|
|
Project: ${projectName}
|
|
Advanced from: ${fromRoundName}
|
|
To: ${toRoundName}
|
|
|
|
${customMessage || 'Our team will be in touch with more details about the next phase.'}
|
|
|
|
${accountUrl
|
|
? `Create your account: ${getBaseUrl()}${accountUrl}`
|
|
: `Visit your dashboard: ${getBaseUrl()}/applicant`}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "Project Not Advanced" (rejection) notification email template
|
|
*/
|
|
export function getRejectionNotificationTemplate(
|
|
name: string,
|
|
projectName: string,
|
|
roundName: string,
|
|
customMessage?: string,
|
|
fullCustomBody?: boolean,
|
|
): EmailTemplate {
|
|
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
|
|
|
|
const escapedMessage = customMessage
|
|
? customMessage
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\n/g, '<br>')
|
|
: null
|
|
|
|
// Full custom body mode: only the custom message inside the branded wrapper
|
|
if (fullCustomBody && escapedMessage) {
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">${escapedMessage}</div>
|
|
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 14px; text-align: center;">
|
|
Thank you for being part of the Monaco Ocean Protection Challenge community.
|
|
</p>
|
|
`
|
|
return {
|
|
subject: `Update on your application: "${projectName}"`,
|
|
html: getEmailWrapper(content),
|
|
text: `${greeting}\n\n${customMessage}\n\nThank you for being part of the Monaco Ocean Protection Challenge community.\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
|
}
|
|
}
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${paragraph(`Thank you for your participation in <strong>${roundName}</strong> with your project <strong>"${projectName}"</strong>.`)}
|
|
${infoBox('After careful review by our jury, we regret to inform you that your project was not selected to advance at this stage.', 'info')}
|
|
${
|
|
escapedMessage
|
|
? `<div style="background-color: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7;">${escapedMessage}</div>`
|
|
: ''
|
|
}
|
|
${paragraph('We encourage you to continue developing your ocean protection initiative and to apply again in future editions. Your commitment to protecting our oceans is valuable and appreciated.')}
|
|
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 14px; text-align: center;">
|
|
Thank you for being part of the Monaco Ocean Protection Challenge community.
|
|
</p>
|
|
`
|
|
|
|
return {
|
|
subject: `Update on your application: "${projectName}"`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
Thank you for your participation in ${roundName} with your project "${projectName}".
|
|
|
|
After careful review by our jury, we regret to inform you that your project was not selected to advance at this stage.
|
|
|
|
${customMessage || ''}
|
|
|
|
We encourage you to continue developing your ocean protection initiative and to apply again in future editions.
|
|
|
|
Thank you for being part of the Monaco Ocean Protection Challenge community.
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate "Under Consideration for Special Award" notification email template
|
|
*/
|
|
export function getAwardSelectionNotificationTemplate(
|
|
name: string,
|
|
projectName: string,
|
|
awardName: string,
|
|
customMessage?: string,
|
|
accountUrl?: string,
|
|
): EmailTemplate {
|
|
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
|
|
|
|
const announcementBanner = `
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
|
<tr>
|
|
<td style="background: linear-gradient(135deg, ${BRAND.darkBlue} 0%, ${BRAND.teal} 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
|
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Special Award Consideration</p>
|
|
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">Your project is under consideration</h2>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
|
|
const escapedMessage = customMessage
|
|
? customMessage
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\n/g, '<br>')
|
|
: null
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${announcementBanner}
|
|
${infoBox(`<strong>"${projectName}"</strong> has been shortlisted for consideration for the <strong>${awardName}</strong>.`, 'info')}
|
|
${paragraph('This means your project has caught the attention of our selection committee and is being evaluated for this special recognition. Please note that this is not a final award — further review and evaluation steps may follow.')}
|
|
${
|
|
escapedMessage
|
|
? `<div style="background-color: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7;">${escapedMessage}</div>`
|
|
: paragraph('Our team will be in touch with more details about this award and next steps.')
|
|
}
|
|
${accountUrl
|
|
? ctaButton(accountUrl, 'Create Your Account')
|
|
: ctaButton('/applicant', 'View Your Dashboard')}
|
|
`
|
|
|
|
return {
|
|
subject: `Your project is under consideration for ${awardName}: "${projectName}"`,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
Your project is under consideration for a special award.
|
|
|
|
Project: ${projectName}
|
|
Award: ${awardName}
|
|
|
|
Your project has been shortlisted for consideration for the ${awardName}. This means your project has caught the attention of our selection committee and is being evaluated for this special recognition. Please note that this is not a final award — further review and evaluation steps may follow.
|
|
|
|
${customMessage || 'Our team will be in touch with more details about this award and next steps.'}
|
|
|
|
${accountUrl
|
|
? `Create your account: ${getBaseUrl()}${accountUrl}`
|
|
: `Visit your dashboard: ${getBaseUrl()}/applicant`}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a preview HTML wrapper for admin email previews
|
|
*/
|
|
export function getEmailPreviewHtml(subject: string, body: string): string {
|
|
const formattedBody = body
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\n/g, '<br>')
|
|
const content = `
|
|
${sectionTitle(subject)}
|
|
<div style="color: #1f2937; font-size: 15px; line-height: 1.7; margin: 20px 0;">
|
|
${formattedBody}
|
|
</div>
|
|
`
|
|
return getEmailWrapper(content)
|
|
}
|
|
|
|
/**
|
|
* Generate "Account Setup Reminder" email template
|
|
* Sent to semi-finalist team members who haven't set up their account yet.
|
|
*/
|
|
export function getAccountReminderTemplate(
|
|
name: string,
|
|
projectName: string,
|
|
accountUrl: string,
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
${paragraph(`Your project <strong>"${projectName}"</strong> has been selected as a semi-finalist in the Monaco Ocean Protection Challenge.`)}
|
|
${infoBox('Please set up your account to access your applicant dashboard and stay up to date with the competition.', 'warning')}
|
|
${ctaButton(accountUrl, 'Set Up Your Account')}
|
|
${paragraph('If you have any questions, please contact the MOPC team.')}
|
|
`
|
|
|
|
return {
|
|
subject: `Action Required: Set up your MOPC account — "${projectName}"`,
|
|
html: getEmailWrapper(content),
|
|
text: `${greeting}\n\nYour project "${projectName}" has been selected as a semi-finalist in the Monaco Ocean Protection Challenge.\n\nPlease set up your account to access your applicant dashboard.\n\nSet up your account: ${getBaseUrl()}${accountUrl}\n\nIf you have any questions, please contact the MOPC team.\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Template registry mapping notification types to template generators
|
|
*/
|
|
type TemplateGenerator = (context: NotificationEmailContext) => EmailTemplate
|
|
|
|
export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
|
|
// Team/Applicant templates
|
|
ADVANCED_SEMIFINAL: (ctx) =>
|
|
getAdvancedSemifinalTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectName as string) || 'Your Project',
|
|
(ctx.metadata?.programName as string) || 'MOPC',
|
|
ctx.metadata?.nextSteps as string | undefined
|
|
),
|
|
ADVANCED_FINAL: (ctx) =>
|
|
getAdvancedFinalTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectName as string) || 'Your Project',
|
|
(ctx.metadata?.programName as string) || 'MOPC',
|
|
ctx.metadata?.nextSteps as string | undefined
|
|
),
|
|
MENTOR_ASSIGNED: (ctx) =>
|
|
getMentorAssignedTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectName as string) || 'Your Project',
|
|
(ctx.metadata?.mentorName as string) || 'Your Mentor',
|
|
ctx.metadata?.mentorBio as string | undefined
|
|
),
|
|
NOT_SELECTED: (ctx) =>
|
|
getNotSelectedTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectName as string) || 'Your Project',
|
|
(ctx.metadata?.roundName as string) || 'this round',
|
|
ctx.linkUrl,
|
|
ctx.metadata?.encouragement as string | undefined
|
|
),
|
|
WINNER_ANNOUNCEMENT: (ctx) =>
|
|
getWinnerAnnouncementTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectName as string) || 'Your Project',
|
|
(ctx.metadata?.awardName as string) || 'the Award',
|
|
ctx.metadata?.prizeDetails as string | undefined
|
|
),
|
|
|
|
// Jury templates
|
|
ASSIGNED_TO_PROJECT: (ctx) =>
|
|
getAssignedToProjectTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectName as string) || 'Project',
|
|
(ctx.metadata?.roundName as string) || 'this round',
|
|
ctx.metadata?.deadline as string | undefined,
|
|
ctx.linkUrl
|
|
),
|
|
COI_REASSIGNED: (ctx) =>
|
|
getCOIReassignedTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectName as string) || 'Project',
|
|
(ctx.metadata?.roundName as string) || 'this round',
|
|
ctx.metadata?.deadline as string | undefined,
|
|
ctx.linkUrl
|
|
),
|
|
MANUAL_REASSIGNED: (ctx) =>
|
|
getManualReassignedTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectNames as string[]) || [(ctx.metadata?.projectName as string) || 'Project'],
|
|
(ctx.metadata?.roundName as string) || 'this round',
|
|
ctx.metadata?.deadline as string | undefined,
|
|
ctx.linkUrl
|
|
),
|
|
DROPOUT_REASSIGNED: (ctx) =>
|
|
getDropoutReassignedTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectNames as string[]) || [(ctx.metadata?.projectName as string) || 'Project'],
|
|
(ctx.metadata?.roundName as string) || 'this round',
|
|
(ctx.metadata?.droppedJurorName as string) || 'a fellow juror',
|
|
ctx.metadata?.deadline as string | undefined,
|
|
ctx.linkUrl
|
|
),
|
|
BATCH_ASSIGNED: (ctx) =>
|
|
getBatchAssignedTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectCount as number) || 1,
|
|
(ctx.metadata?.roundName as string) || 'this round',
|
|
ctx.metadata?.deadline as string | undefined,
|
|
ctx.linkUrl
|
|
),
|
|
ROUND_NOW_OPEN: (ctx) =>
|
|
getRoundNowOpenTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.roundName as string) || 'Evaluation Round',
|
|
(ctx.metadata?.projectCount as number) || 0,
|
|
ctx.metadata?.deadline as string | undefined,
|
|
ctx.linkUrl
|
|
),
|
|
REMINDER_3_DAYS: (ctx) =>
|
|
getReminder3DaysTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.pendingCount as number) || 0,
|
|
(ctx.metadata?.roundName as string) || 'this round',
|
|
(ctx.metadata?.deadline as string) || 'Soon',
|
|
ctx.linkUrl
|
|
),
|
|
REMINDER_24H: (ctx) =>
|
|
getReminder24HTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.pendingCount as number) || 0,
|
|
(ctx.metadata?.roundName as string) || 'this round',
|
|
(ctx.metadata?.deadline as string) || 'Soon',
|
|
ctx.linkUrl
|
|
),
|
|
REMINDER_1H: (ctx) =>
|
|
getReminder1HTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.pendingCount as number) || 0,
|
|
(ctx.metadata?.roundName as string) || 'this round',
|
|
(ctx.metadata?.deadline as string) || 'Very Soon',
|
|
ctx.linkUrl
|
|
),
|
|
AWARD_VOTING_OPEN: (ctx) =>
|
|
getAwardVotingOpenTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.awardName as string) || 'Special Award',
|
|
(ctx.metadata?.finalistCount as number) || 0,
|
|
ctx.metadata?.deadline as string | undefined,
|
|
ctx.linkUrl
|
|
),
|
|
|
|
// Mentor templates
|
|
MENTEE_ASSIGNED: (ctx) =>
|
|
getMenteeAssignedTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectName as string) || 'Project',
|
|
(ctx.metadata?.teamLeadName as string) || 'Team Lead',
|
|
ctx.metadata?.teamLeadEmail as string | undefined,
|
|
ctx.linkUrl
|
|
),
|
|
MENTEE_ADVANCED: (ctx) =>
|
|
getMenteeAdvancedTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectName as string) || 'Project',
|
|
(ctx.metadata?.roundName as string) || 'this round',
|
|
ctx.metadata?.nextRoundName as string | undefined
|
|
),
|
|
MENTEE_WON: (ctx) =>
|
|
getMenteeWonTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectName as string) || 'Project',
|
|
(ctx.metadata?.awardName as string) || 'Award'
|
|
),
|
|
|
|
ADVANCEMENT_NOTIFICATION: (ctx) =>
|
|
getAdvancementNotificationTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectName as string) || 'Your Project',
|
|
(ctx.metadata?.fromRoundName as string) || 'previous round',
|
|
(ctx.metadata?.toRoundName as string) || 'next round',
|
|
ctx.metadata?.customMessage as string | undefined,
|
|
ctx.metadata?.accountUrl as string | undefined,
|
|
ctx.metadata?.fullCustomBody as boolean | undefined,
|
|
),
|
|
REJECTION_NOTIFICATION: (ctx) =>
|
|
getRejectionNotificationTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectName as string) || 'Your Project',
|
|
(ctx.metadata?.roundName as string) || 'this round',
|
|
ctx.metadata?.customMessage as string | undefined,
|
|
ctx.metadata?.fullCustomBody as boolean | undefined,
|
|
),
|
|
|
|
AWARD_SELECTION_NOTIFICATION: (ctx) =>
|
|
getAwardSelectionNotificationTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectName as string) || 'Your Project',
|
|
(ctx.metadata?.awardName as string) || 'Special Award',
|
|
ctx.metadata?.customMessage as string | undefined,
|
|
ctx.metadata?.accountUrl as string | undefined,
|
|
),
|
|
|
|
ACCOUNT_REMINDER: (ctx) =>
|
|
getAccountReminderTemplate(
|
|
ctx.name || '',
|
|
(ctx.metadata?.projectName as string) || 'Your Project',
|
|
(ctx.metadata?.accountUrl as string) || '/accept-invite',
|
|
),
|
|
|
|
// Admin templates
|
|
NEW_APPLICATION: (ctx) =>
|
|
getNewApplicationTemplate(
|
|
(ctx.metadata?.projectName as string) || 'New Project',
|
|
(ctx.metadata?.applicantName as string) || 'Applicant',
|
|
(ctx.metadata?.applicantEmail as string) || '',
|
|
(ctx.metadata?.programName as string) || 'MOPC',
|
|
ctx.linkUrl
|
|
),
|
|
}
|
|
|
|
/**
|
|
* Send styled notification email using the appropriate template
|
|
*/
|
|
export async function sendStyledNotificationEmail(
|
|
email: string,
|
|
name: string,
|
|
type: string,
|
|
context: NotificationEmailContext,
|
|
subjectOverride?: string
|
|
): Promise<void> {
|
|
// Safety net: always ensure linkUrl is absolute before passing to templates
|
|
const safeContext = {
|
|
...context,
|
|
linkUrl: ensureAbsoluteUrl(context.linkUrl),
|
|
}
|
|
const templateGenerator = NOTIFICATION_EMAIL_TEMPLATES[type]
|
|
|
|
let template: EmailTemplate
|
|
|
|
if (templateGenerator) {
|
|
// Use styled template
|
|
template = templateGenerator({ ...safeContext, name })
|
|
// Apply subject override if provided
|
|
if (subjectOverride) {
|
|
template.subject = subjectOverride
|
|
}
|
|
} else {
|
|
// Fall back to generic template
|
|
template = getNotificationEmailTemplate(
|
|
name,
|
|
subjectOverride || safeContext.title,
|
|
safeContext.message,
|
|
safeContext.linkUrl
|
|
)
|
|
}
|
|
|
|
const { transporter, from } = await getTransporter()
|
|
|
|
await transporter.sendMail({
|
|
from,
|
|
to: email,
|
|
subject: template.subject,
|
|
text: template.text,
|
|
html: template.html,
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// Email Sending Functions
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Send magic link email for authentication
|
|
*/
|
|
export async function sendMagicLinkEmail(
|
|
email: string,
|
|
url: string
|
|
): Promise<void> {
|
|
const expiryMinutes = parseInt(process.env.MAGIC_LINK_EXPIRY || '900') / 60
|
|
const template = getMagicLinkTemplate(url, expiryMinutes)
|
|
const { transporter, from } = await getTransporter()
|
|
|
|
await transporter.sendMail({
|
|
from,
|
|
to: email,
|
|
subject: template.subject,
|
|
text: template.text,
|
|
html: template.html,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Send generic invitation email (not round-specific)
|
|
*/
|
|
export async function sendInvitationEmail(
|
|
email: string,
|
|
name: string | null,
|
|
url: string,
|
|
role: string,
|
|
expiryHours?: number
|
|
): Promise<void> {
|
|
const template = getGenericInvitationTemplate(name || '', url, role, expiryHours)
|
|
const { transporter, from } = await getTransporter()
|
|
|
|
await transporter.sendMail({
|
|
from,
|
|
to: email,
|
|
subject: template.subject,
|
|
text: template.text,
|
|
html: template.html,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Send jury invitation email (round-specific)
|
|
*/
|
|
export async function sendJuryInvitationEmail(
|
|
email: string,
|
|
name: string | null,
|
|
url: string,
|
|
roundName: string
|
|
): Promise<void> {
|
|
const template = getJuryInvitationTemplate(name || '', url, roundName)
|
|
const { transporter, from } = await getTransporter()
|
|
|
|
await transporter.sendMail({
|
|
from,
|
|
to: email,
|
|
subject: template.subject,
|
|
text: template.text,
|
|
html: template.html,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Send evaluation reminder email
|
|
*/
|
|
export async function sendEvaluationReminderEmail(
|
|
email: string,
|
|
name: string | null,
|
|
pendingCount: number,
|
|
roundName: string,
|
|
deadline: string,
|
|
assignmentsUrl: string
|
|
): Promise<void> {
|
|
const template = getEvaluationReminderTemplate(
|
|
name || '',
|
|
pendingCount,
|
|
roundName,
|
|
deadline,
|
|
assignmentsUrl
|
|
)
|
|
const { transporter, from } = await getTransporter()
|
|
|
|
await transporter.sendMail({
|
|
from,
|
|
to: email,
|
|
subject: template.subject,
|
|
text: template.text,
|
|
html: template.html,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Send announcement email
|
|
*/
|
|
export async function sendAnnouncementEmail(
|
|
email: string,
|
|
name: string | null,
|
|
title: string,
|
|
message: string,
|
|
ctaText?: string,
|
|
ctaUrl?: string
|
|
): Promise<void> {
|
|
const template = getAnnouncementTemplate(
|
|
name || '',
|
|
title,
|
|
message,
|
|
ctaText,
|
|
ctaUrl
|
|
)
|
|
const { transporter, from } = await getTransporter()
|
|
|
|
await transporter.sendMail({
|
|
from,
|
|
to: email,
|
|
subject: template.subject,
|
|
text: template.text,
|
|
html: template.html,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Send a test email to verify SMTP configuration
|
|
*/
|
|
export async function sendTestEmail(toEmail: string): Promise<boolean> {
|
|
try {
|
|
const content = `
|
|
${sectionTitle('Test Email')}
|
|
${paragraph('This is a test email from the MOPC Portal.')}
|
|
${infoBox('If you received this, your email configuration is working correctly!', 'success')}
|
|
<p style="color: ${BRAND.textMuted}; margin: 20px 0 0 0; font-size: 13px; text-align: center;">
|
|
Sent at ${new Date().toISOString()}
|
|
</p>
|
|
`
|
|
const { transporter, from } = await getTransporter()
|
|
|
|
await transporter.sendMail({
|
|
from,
|
|
to: toEmail,
|
|
subject: 'MOPC Portal - Test Email',
|
|
text: 'This is a test email from the MOPC Portal. If you received this, your email configuration is working correctly.',
|
|
html: getEmailWrapper(content),
|
|
})
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify SMTP connection
|
|
*/
|
|
export async function verifyEmailConnection(): Promise<boolean> {
|
|
try {
|
|
const { transporter } = await getTransporter()
|
|
await transporter.verify()
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send application confirmation email to applicant
|
|
*/
|
|
export async function sendApplicationConfirmationEmail(
|
|
email: string,
|
|
applicantName: string,
|
|
projectName: string,
|
|
programName: string,
|
|
customMessage?: string
|
|
): Promise<void> {
|
|
const template = getApplicationConfirmationTemplate(
|
|
applicantName,
|
|
projectName,
|
|
programName,
|
|
customMessage
|
|
)
|
|
const { transporter, from } = await getTransporter()
|
|
|
|
await transporter.sendMail({
|
|
from,
|
|
to: email,
|
|
subject: template.subject,
|
|
text: template.text,
|
|
html: template.html,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Send team member invite email
|
|
*/
|
|
export async function sendTeamMemberInviteEmail(
|
|
email: string,
|
|
memberName: string,
|
|
projectName: string,
|
|
teamLeadName: string,
|
|
inviteUrl: string
|
|
): Promise<void> {
|
|
const template = getTeamMemberInviteTemplate(
|
|
memberName,
|
|
projectName,
|
|
teamLeadName,
|
|
inviteUrl
|
|
)
|
|
const { transporter, from } = await getTransporter()
|
|
|
|
await transporter.sendMail({
|
|
from,
|
|
to: email,
|
|
subject: template.subject,
|
|
text: template.text,
|
|
html: template.html,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Generate notification email template
|
|
*/
|
|
function getNotificationEmailTemplate(
|
|
name: string,
|
|
subject: string,
|
|
body: string,
|
|
linkUrl?: string
|
|
): EmailTemplate {
|
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
|
|
|
// Format body text preserving line breaks
|
|
const formattedBody = body
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\n/g, '<br>')
|
|
|
|
const content = `
|
|
${sectionTitle(greeting)}
|
|
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">
|
|
${formattedBody}
|
|
</div>
|
|
${linkUrl ? ctaButton(linkUrl, 'View Details') : ''}
|
|
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
|
|
You received this email because of your notification preferences on the MOPC Portal.
|
|
</p>
|
|
`
|
|
|
|
return {
|
|
subject,
|
|
html: getEmailWrapper(content),
|
|
text: `
|
|
${greeting}
|
|
|
|
${body}
|
|
|
|
${linkUrl ? `View details: ${linkUrl}` : ''}
|
|
|
|
---
|
|
Monaco Ocean Protection Challenge
|
|
Together for a healthier ocean.
|
|
`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send notification email (triggered by in-app notification system)
|
|
*/
|
|
export async function sendNotificationEmail(
|
|
email: string,
|
|
name: string,
|
|
subject: string,
|
|
body: string,
|
|
linkUrl?: string
|
|
): Promise<void> {
|
|
const template = getNotificationEmailTemplate(name, subject, body, ensureAbsoluteUrl(linkUrl))
|
|
const { transporter, from } = await getTransporter()
|
|
|
|
await transporter.sendMail({
|
|
from,
|
|
to: email,
|
|
subject: template.subject,
|
|
text: template.text,
|
|
html: template.html,
|
|
})
|
|
}
|