From 8f8eaa5fe8b5630c9b6b564cbbbabd8decec7d91 Mon Sep 17 00:00:00 2001 From: Ryan Salazar Date: Wed, 20 May 2026 17:55:29 +0000 Subject: [PATCH] newsletter: site-wide signup modal with 7-day dismiss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/layout.tsx | 2 + src/components/NewsletterModal.tsx | 208 ++++++++++++++++++++++++ src/styles/tailwind.css | 244 ++++++++++++++++++++++++++++- 3 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 src/components/NewsletterModal.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9241474..67c18b8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import SystemStatusBar from '@/components/SystemStatusBar'; import AskBBAI from '@/components/AskBBAI'; import GoogleAnalytics from '@/components/GoogleAnalytics'; import CookieConsent from '@/components/CookieConsent'; +import NewsletterModal from '@/components/NewsletterModal'; export const viewport: Viewport = { width: 'device-width', @@ -135,6 +136,7 @@ export default function RootLayout({ {children} + ); diff --git a/src/components/NewsletterModal.tsx b/src/components/NewsletterModal.tsx new file mode 100644 index 0000000..7f53fc1 --- /dev/null +++ b/src/components/NewsletterModal.tsx @@ -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(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 ( +
{ + // backdrop click = dismiss (only if clicking the backdrop, not the card) + if (e.target === e.currentTarget) dismiss(); + }} + > +
+ + + {/* Header — accent rule + small label */} +
+
+ +

+ Get the latest in broadcast & media +

+ + {phase !== "success" && ( +

+ Industry news, product launches, and deep-dives from NAB to IBC — direct to your inbox. + One email a week, no spam. +

+ )} + + {phase === "success" ? ( +
+ +

Check your email

+

+ We’ve sent a confirmation to {email}. Click the link to + start receiving the newsletter. +

+
+ ) : ( +
+ + { setEmail(e.target.value); setErrMsg(null); }} + disabled={phase === "submitting"} + className="bb-modal-input" + autoFocus + /> + {errMsg &&
{errMsg}
} + +
+ + +
+ +

+ By subscribing you agree to receive editorial emails from Broadcast Beat. + Unsubscribe anytime — link in every email. +

+
+ )} +
+
+ ); +} diff --git a/src/styles/tailwind.css b/src/styles/tailwind.css index fdfcd59..7d09c02 100644 --- a/src/styles/tailwind.css +++ b/src/styles/tailwind.css @@ -1118,4 +1118,246 @@ h1, h2, h3 { .bb-browse-item[aria-expanded="true"] .bb-browse-caret { transform: rotate(180deg) translateY(1px); -} \ No newline at end of file +} +/* ─── 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; +}