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
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Newsletter subscribe proxy (Session S7, a1) — S25 rule: the browser hits
* the Next origin, never Express directly. Forwards to the backend, which
* forwards to the self-hosted Listmonk (double opt-in).
*
* Failure posture: any upstream problem degrades to the calm
* { ok: false, reason: 'not configured' } shape — the capture component
* renders "Signups open soon", never an error wall.
*/
export async function POST(req: NextRequest) {
let body: unknown = {};
try {
body = await req.json();
} catch {
return NextResponse.json({ ok: false, error: 'Enter a valid email.' }, { status: 400 });
}
try {
const upstream = await fetch(`${BACKEND_URL}/api/newsletter/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
});
const data = await upstream.json().catch(() => ({ ok: false, reason: 'not configured' }));
return NextResponse.json(data, { status: upstream.status === 400 ? 400 : 200 });
} catch {
return NextResponse.json({ ok: false, reason: 'not configured' }, { status: 200 });
}
}
+6
View File
@@ -23,6 +23,8 @@ import Features from '@/components/Features';
import HowItWorks from '@/components/HowItWorks';
import Pricing from '@/components/Pricing';
import FAQ from '@/components/FAQ';
// Session S7 (a1) — THE VYNDR REPORT capture (double opt-in via Listmonk).
import NewsletterCapture from '@/components/NewsletterCapture';
// Footer is mounted globally in the root layout (Session 34) — no per-page import.
// Session 60 (6.1) — the 34s "LOADING THE SLATE" first paint was THIS
@@ -91,6 +93,10 @@ export default function Home() {
<HowItWorks />
<Pricing />
<FAQ />
{/* Session S7 (a1) — the daily wire, by email. Sits above the global footer. */}
<div style={{ maxWidth: 640, margin: '0 auto', padding: '8px 16px 40px' }}>
<NewsletterCapture />
</div>
</>
);
}
+9
View File
@@ -3,6 +3,10 @@
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { useEffect, useRef } from 'react';
// Session S7 (a1) — THE VYNDR REPORT capture on the free-signup success path.
// This page (not the signup "done" card, which auto-redirects after 1.5s) is
// where a new free user actually lingers — the honest place for the ask.
import NewsletterCapture from '@/components/NewsletterCapture';
export default function WelcomePage() {
const router = useRouter();
@@ -84,6 +88,11 @@ export default function WelcomePage() {
</button>
<p className="lbl" style={{ color: 'var(--text-2)' }}>BUILT IN DETROIT</p>
</div>
{/* Session S7 (a1) — the daily wire, by email. Double opt-in via Listmonk. */}
<div style={{ marginTop: 32, width: '100%', maxWidth: 440, textAlign: 'left', zIndex: 1 }}>
<NewsletterCapture compact />
</div>
</section>
);
}