Files
PersonalBlog/js/newsletter.js
T
bot-implementer 24db5b8a50 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.
2026-08-25 19:15:56 +00:00

133 lines
4.3 KiB
JavaScript

/**
* 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);
}