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:
@@ -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}
|
||||
</div>
|
||||
<CookieConsent />
|
||||
<NewsletterModal />
|
||||
<AskBBAI />
|
||||
</body>
|
||||
</html>);
|
||||
|
||||
208
src/components/NewsletterModal.tsx
Normal file
208
src/components/NewsletterModal.tsx
Normal 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 & 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’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>
|
||||
);
|
||||
}
|
||||
@@ -1118,4 +1118,246 @@ h1, h2, h3 {
|
||||
|
||||
.bb-browse-item[aria-expanded="true"] .bb-browse-caret {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user