newsletter: site-wide signup modal with 7-day dismiss

Renders on every page via root layout. Behavior:
- 5s delay after first paint so it doesn't slam the user.
- Animates from a pill into the full card (bb-modal-pop keyframe);
  respects prefers-reduced-motion.
- Heading "Get the latest in broadcast & media", email field,
  Subscribe + "Not now" + close (X).
- Subscribe POSTs to the existing /api/newsletter/subscribe route
  (native system — newsletter_subscribers table + SMTP welcome
  via Mailcow/SMTP2GO). Listmonk wasn't installed anywhere on the
  infra; switching to it later is a one-line URL change.
- "Not now" + backdrop click + Escape + X all dismiss for 7 days
  via localStorage["bb_newsletter_dismissed"].
- On success → "Check your email" panel + auto-close at 4s;
  subscribed flag stored in localStorage so the modal never
  re-prompts that visitor.

Styling matches the site's dark-navy + electric-blue palette
(NOT the neon ticker palette). No layout/page shifts — overlay
only.
This commit is contained in:
Ryan Salazar
2026-05-20 17:55:29 +00:00
parent 418027b469
commit 8f8eaa5fe8
3 changed files with 453 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ import SystemStatusBar from '@/components/SystemStatusBar';
import AskBBAI from '@/components/AskBBAI'; import AskBBAI from '@/components/AskBBAI';
import GoogleAnalytics from '@/components/GoogleAnalytics'; import GoogleAnalytics from '@/components/GoogleAnalytics';
import CookieConsent from '@/components/CookieConsent'; import CookieConsent from '@/components/CookieConsent';
import NewsletterModal from '@/components/NewsletterModal';
export const viewport: Viewport = { export const viewport: Viewport = {
width: 'device-width', width: 'device-width',
@@ -135,6 +136,7 @@ export default function RootLayout({
{children} {children}
</div> </div>
<CookieConsent /> <CookieConsent />
<NewsletterModal />
<AskBBAI /> <AskBBAI />
</body> </body>
</html>); </html>);

View File

@@ -0,0 +1,208 @@
"use client";
import React, { useEffect, useState } from "react";
/**
* NewsletterModal — site-wide popup, shown on first visit per 7-day window.
*
* Behavior:
* - Renders nothing during SSR.
* - On mount: checks localStorage["bb_newsletter_dismissed"]; if the stored
* timestamp is < 7 days old, stays hidden. Same for
* ["bb_newsletter_subscribed"] (no expiry — never re-prompt subscribers).
* - First show is delayed ~5s so it doesn't slam the user the instant the
* page paints; the modal animates from a small pill into the full card
* via the .bb-modal-enter keyframe (defined in tailwind.css).
* - Submit POSTs to /api/newsletter/subscribe (existing native endpoint —
* we already have newsletter_subscribers + an SMTP welcome flow there;
* if you decide to swap to Listmonk later, only this fetch URL changes).
*/
const STORAGE_DISMISS_KEY = "bb_newsletter_dismissed";
const STORAGE_SUB_KEY = "bb_newsletter_subscribed";
const DISMISS_DAYS = 7;
const INITIAL_DELAY_MS = 5000;
function shouldShow(): boolean {
if (typeof window === "undefined") return false;
try {
if (window.localStorage.getItem(STORAGE_SUB_KEY)) return false;
const raw = window.localStorage.getItem(STORAGE_DISMISS_KEY);
if (!raw) return true;
const expires = parseInt(raw, 10);
if (Number.isFinite(expires) && Date.now() < expires) return false;
return true;
} catch {
return true;
}
}
export default function NewsletterModal() {
const [open, setOpen] = useState(false);
const [phase, setPhase] = useState<"form" | "submitting" | "success" | "error">("form");
const [email, setEmail] = useState("");
const [errMsg, setErrMsg] = useState<string | null>(null);
useEffect(() => {
if (!shouldShow()) return;
const t = setTimeout(() => setOpen(true), INITIAL_DELAY_MS);
return () => clearTimeout(t);
}, []);
// Escape closes (treat as dismiss-for-7d)
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") dismiss();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
function dismiss() {
try {
const exp = Date.now() + DISMISS_DAYS * 24 * 60 * 60 * 1000;
window.localStorage.setItem(STORAGE_DISMISS_KEY, String(exp));
} catch {}
setOpen(false);
}
function markSubscribed() {
try {
window.localStorage.setItem(STORAGE_SUB_KEY, String(Date.now()));
} catch {}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setErrMsg(null);
const trimmed = email.trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
setErrMsg("Please enter a valid email address.");
return;
}
setPhase("submitting");
try {
const r = await fetch("/api/newsletter/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: trimmed, source: "site_modal" }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok || data?.error) {
setErrMsg(data?.error || "Something went wrong. Please try again.");
setPhase("error");
return;
}
markSubscribed();
setPhase("success");
// auto-close after 4s on success
setTimeout(() => setOpen(false), 4000);
} catch {
setErrMsg("Network error — please try again.");
setPhase("error");
}
}
if (!open) return null;
return (
<div
className="bb-modal-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="bb-newsletter-heading"
onMouseDown={(e) => {
// backdrop click = dismiss (only if clicking the backdrop, not the card)
if (e.target === e.currentTarget) dismiss();
}}
>
<div className="bb-modal-card bb-modal-enter">
<button
type="button"
aria-label="Close"
className="bb-modal-close"
onClick={dismiss}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/* Header — accent rule + small label */}
<div className="bb-modal-eyebrow">
<span className="bb-modal-eyebrow-dot" aria-hidden="true" />
<span>Broadcast Beat Newsletter</span>
</div>
<h2 id="bb-newsletter-heading" className="bb-modal-heading">
Get the latest in broadcast &amp; media
</h2>
{phase !== "success" && (
<p className="bb-modal-sub">
Industry news, product launches, and deep-dives from NAB to IBC direct to your inbox.
One email a week, no spam.
</p>
)}
{phase === "success" ? (
<div className="bb-modal-success">
<div className="bb-modal-success-icon" aria-hidden="true">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<h3 className="bb-modal-success-title">Check your email</h3>
<p className="bb-modal-success-msg">
We&rsquo;ve sent a confirmation to <strong>{email}</strong>. Click the link to
start receiving the newsletter.
</p>
</div>
) : (
<form className="bb-modal-form" onSubmit={handleSubmit}>
<label className="bb-modal-label" htmlFor="bb-newsletter-email">Work email</label>
<input
id="bb-newsletter-email"
type="email"
autoComplete="email"
required
placeholder="you@broadcaster.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setErrMsg(null); }}
disabled={phase === "submitting"}
className="bb-modal-input"
autoFocus
/>
{errMsg && <div className="bb-modal-err">{errMsg}</div>}
<div className="bb-modal-actions">
<button
type="button"
onClick={dismiss}
className="bb-modal-btn-secondary"
disabled={phase === "submitting"}
>
Not now
</button>
<button
type="submit"
className="bb-modal-btn-primary"
disabled={phase === "submitting"}
>
{phase === "submitting" ? "Subscribing…" : "Subscribe"}
</button>
</div>
<p className="bb-modal-fineprint">
By subscribing you agree to receive editorial emails from Broadcast Beat.
Unsubscribe anytime link in every email.
</p>
</form>
)}
</div>
</div>
);
}

View File

@@ -1118,4 +1118,246 @@ h1, h2, h3 {
.bb-browse-item[aria-expanded="true"] .bb-browse-caret { .bb-browse-item[aria-expanded="true"] .bb-browse-caret {
transform: rotate(180deg) translateY(1px); transform: rotate(180deg) translateY(1px);
} }
/* ─── Newsletter modal (site-wide popup) ──────────────────────────────────── */
.bb-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(5, 10, 15, 0.62);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
animation: bb-modal-backdrop-fade 0.35s ease both;
}
@keyframes bb-modal-backdrop-fade {
from { opacity: 0; }
to { opacity: 1; }
}
.bb-modal-card {
position: relative;
width: 100%;
max-width: 440px;
background:
radial-gradient(120% 70% at 10% -10%, rgba(59,130,246,0.10), transparent 60%),
linear-gradient(180deg, #11192a 0%, #0c121e 100%);
border: 1px solid rgba(59,130,246,0.22);
border-radius: 14px;
padding: 28px 28px 24px;
box-shadow:
0 24px 60px rgba(0,0,0,0.55),
0 0 0 1px rgba(255,255,255,0.02) inset;
color: #e6ecf3;
font-family: var(--font-body);
}
/* Small-to-large open animation — scales the card up from a 65% pill and
fades in. Honors prefers-reduced-motion. */
.bb-modal-enter {
animation: bb-modal-pop 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;
transform-origin: center bottom;
}
@keyframes bb-modal-pop {
0% { opacity: 0; transform: translateY(28px) scale(0.62); border-radius: 999px; }
55% { opacity: 1; transform: translateY(0) scale(1.02); border-radius: 18px; }
100% { opacity: 1; transform: translateY(0) scale(1.00); border-radius: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.bb-modal-enter { animation: bb-modal-fade 0.18s ease both; }
}
@keyframes bb-modal-fade { from { opacity: 0; } to { opacity: 1; } }
.bb-modal-close {
position: absolute;
top: 12px;
right: 12px;
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.06);
color: #98a3b3;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.bb-modal-close:hover {
background: rgba(59,130,246,0.18);
color: #ffffff;
border-color: rgba(59,130,246,0.45);
}
.bb-modal-eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: #3b82f6;
margin-bottom: 14px;
}
.bb-modal-eyebrow-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: #3b82f6;
box-shadow: 0 0 10px rgba(59,130,246,0.65);
animation: bb-modal-pulse 2.2s ease-in-out infinite;
}
@keyframes bb-modal-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(0.85); }
}
.bb-modal-heading {
font-family: var(--font-heading);
font-size: 22px;
font-weight: 800;
line-height: 1.18;
margin: 0 0 8px;
color: #ffffff;
letter-spacing: -0.005em;
}
.bb-modal-sub {
margin: 0 0 18px;
font-size: 13.5px;
line-height: 1.55;
color: #98a3b3;
}
.bb-modal-form { margin: 0; }
.bb-modal-label {
display: block;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #6b7585;
margin-bottom: 6px;
}
.bb-modal-input {
width: 100%;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 8px;
color: #ffffff;
font-size: 14px;
font-family: var(--font-body);
padding: 11px 13px;
outline: 0;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.bb-modal-input:focus {
border-color: #3b82f6;
background: rgba(59,130,246,0.06);
box-shadow: 0 0 0 3px rgba(59,130,246,0.18);
}
.bb-modal-input::placeholder { color: #4f5763; }
.bb-modal-input:disabled { opacity: 0.5; cursor: not-allowed; }
.bb-modal-err {
margin-top: 8px;
padding: 8px 10px;
background: rgba(239,68,68,0.10);
border: 1px solid rgba(239,68,68,0.32);
border-radius: 6px;
color: #fca5a5;
font-size: 12.5px;
}
.bb-modal-actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
.bb-modal-btn-primary,
.bb-modal-btn-secondary {
flex: 1;
padding: 11px 16px;
border-radius: 8px;
font-family: var(--font-body);
font-size: 13.5px;
font-weight: 700;
letter-spacing: 0.01em;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
border: 1px solid transparent;
}
.bb-modal-btn-primary {
background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
color: #ffffff;
box-shadow: 0 6px 18px rgba(59,130,246,0.28);
}
.bb-modal-btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba(59,130,246,0.40);
}
.bb-modal-btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
}
.bb-modal-btn-secondary {
background: transparent;
color: #98a3b3;
border-color: rgba(255,255,255,0.10);
}
.bb-modal-btn-secondary:hover:not(:disabled) {
color: #ffffff;
border-color: rgba(255,255,255,0.22);
background: rgba(255,255,255,0.04);
}
.bb-modal-btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
.bb-modal-fineprint {
margin: 14px 0 0;
font-size: 11px;
color: #5a6470;
line-height: 1.45;
}
/* Success state */
.bb-modal-success {
text-align: center;
padding: 6px 0 8px;
}
.bb-modal-success-icon {
width: 56px;
height: 56px;
margin: 0 auto 14px;
border-radius: 50%;
background: rgba(59,130,246,0.14);
border: 1px solid rgba(59,130,246,0.45);
display: inline-flex;
align-items: center;
justify-content: center;
color: #3b82f6;
}
.bb-modal-success-title {
font-family: var(--font-heading);
font-size: 19px;
font-weight: 800;
color: #ffffff;
margin: 0 0 6px;
}
.bb-modal-success-msg {
color: #98a3b3;
font-size: 13.5px;
line-height: 1.55;
margin: 0;
}
.bb-modal-success-msg strong {
color: #ffffff;
font-weight: 600;
}