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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 3–4s "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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user