diff --git a/contact.html b/contact.html index f442c69..cf224e0 100644 --- a/contact.html +++ b/contact.html @@ -14,6 +14,7 @@
  • Home
  • Reading
  • Contact
  • +
  • Newsletter
  • diff --git a/css/style.css b/css/style.css index eef36d8..0cc0a3c 100644 --- a/css/style.css +++ b/css/style.css @@ -120,6 +120,30 @@ button[type="submit"]:hover { 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-category { margin-bottom: 2rem; diff --git a/index.html b/index.html index ca8bb82..8a6c2da 100644 --- a/index.html +++ b/index.html @@ -14,6 +14,7 @@
  • Home
  • Reading
  • Contact
  • +
  • Newsletter
  • diff --git a/js/newsletter-config.js b/js/newsletter-config.js new file mode 100644 index 0000000..ac8f9f4 --- /dev/null +++ b/js/newsletter-config.js @@ -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 = ""; diff --git a/js/newsletter.js b/js/newsletter.js new file mode 100644 index 0000000..fbe176e --- /dev/null +++ b/js/newsletter.js @@ -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); +} diff --git a/newsletter.html b/newsletter.html new file mode 100644 index 0000000..3bf2871 --- /dev/null +++ b/newsletter.html @@ -0,0 +1,39 @@ + + + + + + Newsletter | My Personal Blog + + + + +
    +

    Newsletter

    +

    Occasional updates on new posts — no spam, unsubscribe anytime. Enter your email to subscribe.

    + +
    +
    + + +
    + + +
    +
    + + + + diff --git a/reading.html b/reading.html index 09d9219..a42b7de 100644 --- a/reading.html +++ b/reading.html @@ -14,6 +14,7 @@
  • Home
  • Reading
  • Contact
  • +
  • Newsletter