feat: add serverless newsletter signup page
Adds newsletter.html with an email signup form that POSTs to a configured serverless endpoint. The endpoint and API token live in js/newsletter-config.js (token defaults to an empty deploy-time placeholder, never hardcoded in page logic). Missing/invalid tokens and network failures surface a fixed user-safe error; successful signups show a confirmation. Links the page from the site navigation on all pages.
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
<li><a href="index.html">Home</a></li>
|
<li><a href="index.html">Home</a></li>
|
||||||
<li><a href="reading.html">Reading</a></li>
|
<li><a href="reading.html">Reading</a></li>
|
||||||
<li><a href="contact.html" aria-current="page">Contact</a></li>
|
<li><a href="contact.html" aria-current="page">Contact</a></li>
|
||||||
|
<li><a href="newsletter.html">Newsletter</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -120,6 +120,30 @@ button[type="submit"]:hover {
|
|||||||
filter: brightness(1.1);
|
filter: brightness(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Newsletter signup --- */
|
||||||
|
.newsletter-form {
|
||||||
|
max-width: 36rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-form .form-field input {
|
||||||
|
max-width: 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-status {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-status[data-kind="success"] {
|
||||||
|
color: #1e7e34;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newsletter-status[data-kind="error"] {
|
||||||
|
color: #b3261e;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Reading list --- */
|
/* --- Reading list --- */
|
||||||
.reading-category {
|
.reading-category {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<li><a href="index.html">Home</a></li>
|
<li><a href="index.html">Home</a></li>
|
||||||
<li><a href="reading.html">Reading</a></li>
|
<li><a href="reading.html">Reading</a></li>
|
||||||
<li><a href="contact.html">Contact</a></li>
|
<li><a href="contact.html">Contact</a></li>
|
||||||
|
<li><a href="newsletter.html">Newsletter</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Newsletter signup configuration.
|
||||||
|
*
|
||||||
|
* The blog is a static site with no build step, so the serverless endpoint
|
||||||
|
* and its API token are configured here — edit this file when deploying.
|
||||||
|
*
|
||||||
|
* Security note: a real API token must NEVER be committed to the repository.
|
||||||
|
* The committed default below is intentionally empty; the deployer fills in
|
||||||
|
* the token at deploy time (and only then). The page logic reads the token
|
||||||
|
* from this module and never hardcodes one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The serverless endpoint the signup form POSTs to. */
|
||||||
|
export const NEWSLETTER_ENDPOINT = "https://example.com/api/newsletter-subscribers";
|
||||||
|
|
||||||
|
/** API token for the endpoint. Empty by default — set at deploy time. */
|
||||||
|
export const NEWSLETTER_API_TOKEN = "";
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Newsletter signup — posts the visitor's email to a configured serverless
|
||||||
|
* endpoint using the API token from `newsletter-config.js`.
|
||||||
|
*
|
||||||
|
* Pure-ish by design (no DOM, injectable fetch), so every behaviour is unit
|
||||||
|
* testable in Node; the browser wiring at the bottom is guarded accordingly.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
NEWSLETTER_API_TOKEN,
|
||||||
|
NEWSLETTER_ENDPOINT,
|
||||||
|
} from "./newsletter-config.js";
|
||||||
|
|
||||||
|
/** User-safe message shown when the signup cannot be completed. */
|
||||||
|
export const NEWSLETTER_ERROR_MESSAGE =
|
||||||
|
"Sorry, the newsletter signup isn't available right now. Please try again later.";
|
||||||
|
|
||||||
|
/** Confirmation message shown after a successful signup. */
|
||||||
|
export const NEWSLETTER_SUCCESS_MESSAGE = "Thanks for subscribing!";
|
||||||
|
|
||||||
|
/** Simple email shape check; the HTML5 `type="email"` input is the primary gate. */
|
||||||
|
export function isValidEmail(value) {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the trimmed email value from a form.
|
||||||
|
* Works with a real DOM form and with the minimal fake form used in tests.
|
||||||
|
*
|
||||||
|
* @param {{querySelectorAll(selector: string): ArrayLike<{name: string, value: string}>}} form
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function readFormEmail(form) {
|
||||||
|
for (const el of form.querySelectorAll("[name]")) {
|
||||||
|
if (el.name === "email") return el.value.trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST the email to the serverless endpoint with the API token.
|
||||||
|
*
|
||||||
|
* The token travels in the `Authorization` header; it is never put in the
|
||||||
|
* body, the URL, or any message. A missing token fails fast with a user-safe
|
||||||
|
* error and no network call. Any non-2xx response (invalid token, endpoint
|
||||||
|
* error) and any network failure map to the same generic message, so the
|
||||||
|
* secret and endpoint internals are never surfaced to the visitor.
|
||||||
|
*
|
||||||
|
* @param {string} email
|
||||||
|
* @param {{endpoint?: string, token?: string, fetchImpl?: typeof fetch}} [options]
|
||||||
|
* @returns {Promise<{ok: boolean, message: string}>}
|
||||||
|
*/
|
||||||
|
export async function submitNewsletterSignup(
|
||||||
|
email,
|
||||||
|
{ endpoint = NEWSLETTER_ENDPOINT, token = NEWSLETTER_API_TOKEN, fetchImpl = fetch } = {}
|
||||||
|
) {
|
||||||
|
if (!token) {
|
||||||
|
return { ok: false, message: NEWSLETTER_ERROR_MESSAGE };
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetchImpl(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return { ok: false, message: NEWSLETTER_ERROR_MESSAGE };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { ok: false, message: NEWSLETTER_ERROR_MESSAGE };
|
||||||
|
}
|
||||||
|
return { ok: true, message: NEWSLETTER_SUCCESS_MESSAGE };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a newsletter form submit: validate the email, POST it, and surface
|
||||||
|
* the result (confirmation or user-safe error) through `setStatus`.
|
||||||
|
*
|
||||||
|
* @param {HTMLFormElement} form
|
||||||
|
* @param {{endpoint?: string, token?: string, fetchImpl?: typeof fetch, setStatus?: (message: string, kind: "success"|"error") => void}} [options]
|
||||||
|
* @returns {Promise<{ok: boolean, message: string}>}
|
||||||
|
*/
|
||||||
|
export async function handleNewsletterSubmit(
|
||||||
|
form,
|
||||||
|
{
|
||||||
|
endpoint = NEWSLETTER_ENDPOINT,
|
||||||
|
token = NEWSLETTER_API_TOKEN,
|
||||||
|
fetchImpl = fetch,
|
||||||
|
setStatus = defaultSetStatus,
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const email = readFormEmail(form);
|
||||||
|
if (!isValidEmail(email)) {
|
||||||
|
setStatus(NEWSLETTER_ERROR_MESSAGE, "error");
|
||||||
|
return { ok: false, message: NEWSLETTER_ERROR_MESSAGE };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await submitNewsletterSignup(email, { endpoint, token, fetchImpl });
|
||||||
|
setStatus(result.message, result.ok ? "success" : "error");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default status renderer — puts the message into `#newsletter-status`. */
|
||||||
|
function defaultSetStatus(message, kind) {
|
||||||
|
const el = document.getElementById("newsletter-status");
|
||||||
|
if (el) {
|
||||||
|
el.textContent = message;
|
||||||
|
el.dataset.kind = kind;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire the submit handler onto a newsletter form.
|
||||||
|
* @param {HTMLFormElement} form
|
||||||
|
*/
|
||||||
|
export function initNewsletterForm(form, options = {}) {
|
||||||
|
form.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleNewsletterSubmit(form, options);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser-only wiring — guarded so this module stays importable in Node tests.
|
||||||
|
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
||||||
|
const form = document.getElementById("newsletter-form");
|
||||||
|
if (form) initNewsletterForm(form);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Newsletter | My Personal Blog</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<nav class="site-nav" aria-label="Main navigation">
|
||||||
|
<a class="brand" href="index.html">My Blog</a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="index.html">Home</a></li>
|
||||||
|
<li><a href="reading.html">Reading</a></li>
|
||||||
|
<li><a href="contact.html">Contact</a></li>
|
||||||
|
<li><a href="newsletter.html" aria-current="page">Newsletter</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<h1>Newsletter</h1>
|
||||||
|
<p>Occasional updates on new posts — no spam, unsubscribe anytime. Enter your email to subscribe.</p>
|
||||||
|
|
||||||
|
<form id="newsletter-form" class="newsletter-form">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="newsletter-email">Email</label>
|
||||||
|
<input type="email" id="newsletter-email" name="email" required autocomplete="email" placeholder="you@example.com" />
|
||||||
|
</div>
|
||||||
|
<button type="submit">Subscribe</button>
|
||||||
|
<p id="newsletter-status" class="newsletter-status" role="status" aria-live="polite"></p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<footer class="site-footer">
|
||||||
|
<p>© 2026 My Personal Blog</p>
|
||||||
|
</footer>
|
||||||
|
<script type="module" src="js/newsletter.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<li><a href="index.html">Home</a></li>
|
<li><a href="index.html">Home</a></li>
|
||||||
<li><a href="reading.html" aria-current="page">Reading</a></li>
|
<li><a href="reading.html" aria-current="page">Reading</a></li>
|
||||||
<li><a href="contact.html">Contact</a></li>
|
<li><a href="contact.html">Contact</a></li>
|
||||||
|
<li><a href="newsletter.html">Newsletter</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
Reference in New Issue
Block a user