Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Backend email templates.
|
||||
*
|
||||
* These produce the `{subject, html, text}` payload that the broadcast
|
||||
* layer (Listmonk, Ghost, etc.) ships to subscribers. We do not call SMTP
|
||||
* directly — the broadcast platform handles deliverability, unsubscribe
|
||||
* tokens, list management.
|
||||
*
|
||||
* Subject lines are content-driven per spec: NOT "VYNDR Daily Cheatsheet
|
||||
* — May 28"; instead something like "Jokic A+, 3 kills on the Lakers
|
||||
* game, 8 games tonight".
|
||||
*
|
||||
* SECURITY: every interpolated value passes through `esc()` so no
|
||||
* subscriber-controlled string can inject HTML.
|
||||
*/
|
||||
|
||||
const SITE = process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app';
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function htmlShell(body) {
|
||||
return `<!doctype html>
|
||||
<html><body style="margin:0;padding:32px 16px;background:#06060B;color:#E8E8F0;font-family:'Instrument Sans',-apple-system,system-ui,sans-serif;line-height:1.6">
|
||||
<div style="max-width:600px;margin:0 auto;background:#0E0E16;border:1px solid #1E1E2E;border-radius:16px;padding:32px">
|
||||
<h1 style="font-family:'IBM Plex Mono','JetBrains Mono',monospace;font-size:22px;font-weight:800;letter-spacing:0.10em;margin:0 0 24px;color:#E8E8F0">
|
||||
VYND<span style="color:#00D4A0;text-shadow:0 0 12px rgba(0,212,160,0.6)">R</span>
|
||||
</h1>
|
||||
${body}
|
||||
<hr style="border:none;border-top:1px solid #1E1E2E;margin:32px 0 16px" />
|
||||
<p style="font-size:11px;color:#4A4A5E;margin:0;font-family:'IBM Plex Mono',monospace">
|
||||
VYNDR is an analytics tool, not a sportsbook. Gamble responsibly. 1-800-522-4700.<br/>
|
||||
Reply STOP to unsubscribe.
|
||||
</p>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
// ── Cheatsheet — daily 4:30 PM ET ────────────────────────────────────────
|
||||
|
||||
function buildCheatsheetEmail(cheatsheetData = {}) {
|
||||
const grades = Array.isArray(cheatsheetData.grades) ? cheatsheetData.grades : [];
|
||||
const top = grades[0];
|
||||
const gameCount = cheatsheetData.gameCount ?? 0;
|
||||
|
||||
// Content-driven subject:
|
||||
// "Jokic A+, 3 kills on the Lakers game, 8 games tonight"
|
||||
const parts = [];
|
||||
if (top) parts.push(`${top.player || 'top'} ${top.grade || ''}`.trim());
|
||||
if (cheatsheetData.killCount && cheatsheetData.killTeam) {
|
||||
parts.push(`${cheatsheetData.killCount} kills on the ${cheatsheetData.killTeam} game`);
|
||||
}
|
||||
parts.push(`${gameCount} game${gameCount === 1 ? '' : 's'} tonight`);
|
||||
const subject = parts.join(', ');
|
||||
|
||||
const rows = grades.slice(0, 6).map((g) => `
|
||||
<tr>
|
||||
<td style="padding:10px 0;color:#00D4A0;font-family:'IBM Plex Mono',monospace;font-weight:800;font-size:18px;width:60px">${esc(g.grade)}</td>
|
||||
<td style="padding:10px 0;color:#E8E8F0;font-weight:600">${esc(g.player)}</td>
|
||||
<td style="padding:10px 0;color:#7A7A8E;font-family:'IBM Plex Mono',monospace;text-align:right">${esc(`${g.stat || ''} ${String(g.direction || '').toUpperCase()} ${g.line ?? ''}`)}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const body = `
|
||||
<p style="font-size:16px"><strong>Tonight's cheatsheet.</strong></p>
|
||||
<p style="color:#7A7A8E;font-size:14px">${esc(cheatsheetData.date || '')} · ${esc(String(gameCount))} games</p>
|
||||
<table style="width:100%;border-collapse:collapse;margin:16px 0">${rows}</table>
|
||||
${cheatsheetData.imageUrl ? `<p style="margin:16px 0"><img src="${esc(cheatsheetData.imageUrl)}" alt="VYNDR cheatsheet" style="max-width:100%;border-radius:12px;display:block"/></p>` : ''}
|
||||
<p style="margin-top:24px">
|
||||
<a href="${esc(SITE)}/dashboard"
|
||||
style="display:inline-block;padding:12px 24px;background:#1A5A42;color:#E8FFF4;text-decoration:none;border-radius:12px;font-weight:600">
|
||||
See the full slate →
|
||||
</a>
|
||||
</p>`;
|
||||
|
||||
const text =
|
||||
`Tonight's cheatsheet.
|
||||
${cheatsheetData.date || ''} · ${gameCount} games
|
||||
|
||||
${grades.slice(0, 6).map((g) => `${g.grade}\t${g.player}\t${g.stat || ''} ${String(g.direction || '').toUpperCase()} ${g.line ?? ''}`).join('\n')}
|
||||
|
||||
See the full slate: ${SITE}/dashboard
|
||||
|
||||
VYNDR · vyndr.app · @getvyndr
|
||||
Reply STOP to unsubscribe.`;
|
||||
|
||||
return { subject, html: htmlShell(body), text };
|
||||
}
|
||||
|
||||
// ── Results — daily 9 AM ET ──────────────────────────────────────────────
|
||||
|
||||
function buildResultsEmail(resultsData = {}) {
|
||||
const accuracy = resultsData.accuracy ?? 0;
|
||||
const hits = resultsData.hits ?? 0;
|
||||
const total = resultsData.total ?? 0;
|
||||
const entries = Array.isArray(resultsData.entries) ? resultsData.entries : [];
|
||||
|
||||
const subject = `Last night: ${accuracy}% — ${hits} of ${total} grades hit`;
|
||||
|
||||
const rows = entries.slice(0, 8).map((e) => {
|
||||
const hit = e.result === 'hit';
|
||||
const tint = hit ? 'rgba(0,212,160,0.10)' : 'rgba(255,82,82,0.08)';
|
||||
const mark = hit ? '✓' : '✗';
|
||||
const markColor = hit ? '#00D4A0' : '#FF5252';
|
||||
return `
|
||||
<tr style="background:${tint}">
|
||||
<td style="padding:10px 12px;color:${markColor};font-family:'IBM Plex Mono',monospace;font-weight:800;font-size:18px;width:32px">${mark}</td>
|
||||
<td style="padding:10px 12px;color:#E8E8F0;font-weight:600">${esc(e.player)}</td>
|
||||
<td style="padding:10px 12px;color:#7A7A8E;font-family:'IBM Plex Mono',monospace;font-size:13px">${esc(`${e.stat || ''} ${String(e.direction || '').toUpperCase()} ${e.line ?? ''}`)}</td>
|
||||
<td style="padding:10px 12px;color:#00D4A0;font-family:'IBM Plex Mono',monospace;font-weight:800;text-align:right">${esc(e.grade)}</td>
|
||||
<td style="padding:10px 12px;color:${markColor};font-family:'IBM Plex Mono',monospace;font-weight:700;text-align:right">${hit ? 'HIT' : 'MISS'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const body = `
|
||||
<p style="font-size:16px"><strong>Last night's results.</strong></p>
|
||||
<p style="color:#7A7A8E;font-size:14px">${esc(resultsData.date || '')}</p>
|
||||
<p style="font-family:'IBM Plex Mono',monospace;font-size:32px;font-weight:800;color:#00D4A0;text-shadow:0 0 12px rgba(0,212,160,0.6);margin:8px 0">
|
||||
${accuracy}% <span style="font-size:14px;color:#7A7A8E">(${hits}/${total})</span>
|
||||
</p>
|
||||
<table style="width:100%;border-collapse:separate;border-spacing:0 4px;margin:16px 0">${rows}</table>
|
||||
${resultsData.imageUrl ? `<p style="margin:16px 0"><img src="${esc(resultsData.imageUrl)}" alt="VYNDR recap" style="max-width:100%;border-radius:12px;display:block"/></p>` : ''}
|
||||
<p style="margin-top:24px">
|
||||
<a href="${esc(SITE)}/ledger"
|
||||
style="display:inline-block;padding:12px 24px;background:#1A5A42;color:#E8FFF4;text-decoration:none;border-radius:12px;font-weight:600">
|
||||
See the full Ledger →
|
||||
</a>
|
||||
</p>`;
|
||||
|
||||
const text =
|
||||
`Last night: ${accuracy}% — ${hits} of ${total} grades hit
|
||||
${resultsData.date || ''}
|
||||
|
||||
${entries.slice(0, 8).map((e) => `${e.result === 'hit' ? 'HIT' : 'MISS'}\t${e.player}\t${e.stat || ''} ${String(e.direction || '').toUpperCase()} ${e.line ?? ''}\t${e.grade}`).join('\n')}
|
||||
|
||||
Full Ledger: ${SITE}/ledger
|
||||
|
||||
VYNDR · vyndr.app · @getvyndr
|
||||
Reply STOP to unsubscribe.`;
|
||||
|
||||
return { subject, html: htmlShell(body), text };
|
||||
}
|
||||
|
||||
// ── Cascade alert — real-time ────────────────────────────────────────────
|
||||
|
||||
function buildCascadeAlertEmail(cascadeData = {}) {
|
||||
const trigger = cascadeData.trigger_detail || cascadeData.detail || {};
|
||||
const headline = trigger.player && trigger.status
|
||||
? `${trigger.player} ${trigger.status}`
|
||||
: (trigger.summary || cascadeData.trigger_type || 'event');
|
||||
const count = Array.isArray(cascadeData.affected_props) ? cascadeData.affected_props.length : (cascadeData.affected_count ?? 0);
|
||||
|
||||
const subject = `🚨 ${headline} — ${count} prop${count === 1 ? '' : 's'} affected`;
|
||||
|
||||
const rows = (cascadeData.affected_props || []).slice(0, 6).map((p) => `
|
||||
<tr>
|
||||
<td style="padding:10px 0;color:#E8E8F0;font-weight:600">${esc(p.player)}</td>
|
||||
<td style="padding:10px 0;color:#7A7A8E;font-family:'IBM Plex Mono',monospace;font-size:13px">${esc(`${p.stat || ''} ${String(p.direction || '').toUpperCase()} ${p.line ?? ''}`)}</td>
|
||||
<td style="padding:10px 0;color:#7A7A8E;font-family:'IBM Plex Mono',monospace;font-weight:700;text-align:right">${esc(p.old_grade || '—')} → <span style="color:#00D4A0">${esc(p.new_grade || '—')}</span></td>
|
||||
</tr>`).join('');
|
||||
|
||||
const body = `
|
||||
<p style="font-family:'IBM Plex Mono',monospace;font-size:13px;letter-spacing:4px;color:#FFB347;text-transform:uppercase;margin-bottom:8px">CASCADE · ${esc((cascadeData.trigger_type || 'event').toUpperCase())}</p>
|
||||
<p style="font-size:18px;font-weight:700;margin:0">${esc(headline)} → ${count} prop${count === 1 ? '' : 's'} affected.</p>
|
||||
<table style="width:100%;border-collapse:collapse;margin:16px 0">${rows}</table>
|
||||
${cascadeData.imageUrl ? `<p style="margin:16px 0"><img src="${esc(cascadeData.imageUrl)}" alt="VYNDR cascade" style="max-width:100%;border-radius:12px;display:block"/></p>` : ''}
|
||||
<p style="margin-top:20px">
|
||||
<a href="${esc(SITE)}/dashboard"
|
||||
style="display:inline-block;padding:12px 24px;background:#1A5A42;color:#E8FFF4;text-decoration:none;border-radius:12px;font-weight:600">
|
||||
See the updated grades →
|
||||
</a>
|
||||
</p>`;
|
||||
|
||||
const text =
|
||||
`${headline} — ${count} prop${count === 1 ? '' : 's'} affected.
|
||||
|
||||
${(cascadeData.affected_props || []).slice(0, 6).map((p) => `${p.player}\t${p.stat || ''} ${String(p.direction || '').toUpperCase()} ${p.line ?? ''}\t${p.old_grade || '—'} → ${p.new_grade || '—'}`).join('\n')}
|
||||
|
||||
Updated grades: ${SITE}/dashboard
|
||||
|
||||
VYNDR · vyndr.app · @getvyndr
|
||||
Reply STOP to unsubscribe.`;
|
||||
|
||||
return { subject, html: htmlShell(body), text };
|
||||
}
|
||||
|
||||
module.exports = { buildCheatsheetEmail, buildResultsEmail, buildCascadeAlertEmail };
|
||||
Reference in New Issue
Block a user