S7 (a1): newsletter — THE VYNDR REPORT

Email capture + daily report assembly + operator-triggered Listmonk send.

- NewsletterCapture (dark terminal, mono) on landing (below FAQ) + /welcome
  (the real signup success surface); double-opt-in note; 'Signups open soon'
  when Listmonk env is unset.
- POST /api/newsletter/subscribe: public, 10/min IP limit, honeypot,
  server-side email validation, forwards to Listmonk subscribers API with
  preconfirm_subscriptions:false (Listmonk sends the confirmation).
  No env -> calm 200 { ok:false, reason:'not configured' }. Next proxy
  web/src/app/api/newsletter/subscribe/route.ts (S25 rule).
- newsletterService.buildDailyReport: signals from snapshot:{sport}:latest,
  STREAK WATCH via rosterLogs -> streaksService -> streakLens, THE RECORD via
  ledgerService.getModelAggregate (percentage only when hit_pct != null —
  n>=20 gate — else 'RECORD BUILDING · N pending'). RG footer (21+,
  1-800-GAMBLER, Listmonk-native {{ UnsubscribeURL }}) in html + text.
  VOICE v1.1 lint locked by tests: no '!', no banned vocabulary, numbers
  only from injected pipeline data.
- sendDailyReport: creates + starts a Listmonk campaign; env-gated no-op;
  refuses an empty report. Deliberately UNSCHEDULED — only
  POST /api/internal/newsletter/send (internal key) triggers it.
- docs/NEWSLETTER.md: box-side Listmonk runbook (install, double-opt-in
  list, API user, Coolify env, test-send).
- Spec: specs/feature-a1-s7-newsletter.md.

Tests 2398 -> 2429 (207 suites, all green); next build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-11 14:29:22 -04:00
parent e4d2e79f95
commit 32d7200571
14 changed files with 1300 additions and 1 deletions
+152
View File
@@ -0,0 +1,152 @@
'use client';
import { useState, type FormEvent } from 'react';
/**
* THE VYNDR REPORT capture (Session S7, a1) — email field for the daily
* newsletter. Dark terminal surface, mono for data (the email IS data),
* VOICE v1.1 copy: deadpan, no exclamation points.
*
* States:
* idle → busy → sent ("Confirmation sent. Check your inbox.")
* soon (backend not configured → "Signups open soon.")
* error (bad email / transient upstream)
*
* Double opt-in is real: the backend asks Listmonk to send a confirmation;
* nothing lands on the list until the reader clicks it. The note under the
* field says exactly that.
*/
export default function NewsletterCapture({ compact = false }: { compact?: boolean }) {
const [email, setEmail] = useState('');
const [website, setWebsite] = useState(''); // honeypot — humans never see it
const [state, setState] = useState<'idle' | 'busy' | 'sent' | 'soon' | 'error'>('idle');
const [message, setMessage] = useState('');
const submit = async (e: FormEvent) => {
e.preventDefault();
if (state === 'busy') return;
setState('busy');
setMessage('');
try {
const res = await fetch('/api/newsletter/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), website }),
});
const data = await res.json().catch(() => ({ ok: false, reason: 'not configured' }));
if (data.ok) {
setState('sent');
} else if (res.status === 400) {
setState('error');
setMessage(data.error || 'Enter a valid email.');
} else {
// 'not configured' or transient upstream — calm either way.
setState('soon');
}
} catch {
setState('soon');
}
};
if (state === 'sent') {
return (
<div
className="surface"
style={{ padding: compact ? '16px 18px' : '22px 24px', border: '1px solid var(--border)', borderRadius: 8 }}
>
<div className="mono" style={{ fontSize: 11, letterSpacing: '0.14em', color: 'var(--g-a)', marginBottom: 6 }}>
THE VYNDR REPORT
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', margin: 0 }}>
Confirmation sent. Check your inbox nothing arrives until you click it.
</p>
</div>
);
}
return (
<div
className="surface"
style={{ padding: compact ? '16px 18px' : '22px 24px', border: '1px solid var(--border)', borderRadius: 8 }}
>
<div className="mono" style={{ fontSize: 11, letterSpacing: '0.14em', color: 'var(--g-a)', marginBottom: 6 }}>
THE VYNDR REPORT
</div>
<p style={{ fontSize: compact ? 13 : 14, color: 'var(--text-0, var(--text-primary))', margin: '0 0 12px' }}>
The slate, the signals, the settle. Daily. Free.
</p>
{state === 'soon' ? (
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
Signups open soon.
</p>
) : (
<form onSubmit={submit} style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{/* Honeypot — visually hidden, tab-skipped. Bots fill it, humans don't. */}
<input
type="text"
name="website"
value={website}
onChange={(e) => setWebsite(e.target.value)}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
style={{ position: 'absolute', left: -9999, width: 1, height: 1, opacity: 0 }}
/>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@wherever.com"
aria-label="Email for THE VYNDR REPORT"
className="mono"
style={{
flex: '1 1 200px',
minWidth: 0,
background: 'var(--bg-2, transparent)',
border: '1px solid var(--border)',
borderRadius: 6,
padding: '10px 12px',
fontSize: 13,
color: 'var(--text-0, var(--text-primary))',
outline: 'none',
}}
/>
<button
type="submit"
disabled={state === 'busy'}
className="mono"
style={{
background: 'var(--g-a, var(--grade-a))',
color: '#04140f',
border: '1px solid var(--g-a, var(--grade-a))',
borderRadius: 6,
padding: '10px 18px',
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.08em',
cursor: state === 'busy' ? 'wait' : 'pointer',
opacity: state === 'busy' ? 0.6 : 1,
}}
>
{state === 'busy' ? 'SENDING' : 'SUBSCRIBE'}
</button>
</form>
)}
{state === 'error' && message && (
<p className="mono" style={{ fontSize: 12, color: 'var(--miss, #ff8a8a)', margin: '8px 0 0' }}>
{message}
</p>
)}
{state !== 'soon' && (
<p style={{ fontSize: 11, color: 'var(--text-tertiary, var(--text-secondary))', margin: '10px 0 0', lineHeight: 1.5 }}>
Double opt-in a confirmation email lands first; nothing sends until you click it.
Unsubscribe is one click, forever. 21+.
</p>
)}
</div>
);
}