[Story] Contact page #6
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Contact | 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="contact.html" aria-current="page">Contact</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h1>Contact</h1>
|
||||
<p>Use the form below to get in touch — submitting it opens your mail client with the details pre-filled.</p>
|
||||
|
||||
<form id="contact-form" class="contact-form">
|
||||
<div class="form-field">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required autocomplete="name" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required autocomplete="email" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="message">Message</label>
|
||||
<textarea id="message" name="message" rows="6" required></textarea>
|
||||
</div>
|
||||
<button type="submit">Send message</button>
|
||||
</form>
|
||||
</main>
|
||||
<footer class="site-footer">
|
||||
<p>© 2026 My Personal Blog</p>
|
||||
</footer>
|
||||
<script type="module" src="js/contact.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,6 +12,7 @@
|
||||
<a class="brand" href="index.html">My Blog</a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="index.html">Home</a></li>
|
||||
<li><a href="contact.html">Contact</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { buildMailtoUrl } from "./mailto.js";
|
||||
|
||||
/** The address the contact form messages are sent to. */
|
||||
export const MAILTO_TARGET = "hello@example.com";
|
||||
|
||||
/**
|
||||
* Collect form field values by name.
|
||||
* 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 {{name: string, email: string, message: string}}
|
||||
*/
|
||||
export function readFormValues(form) {
|
||||
const values = {};
|
||||
for (const el of form.querySelectorAll("[name]")) {
|
||||
values[el.name] = el.value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the mailto: URL for the contact form from the entered values.
|
||||
*
|
||||
* @param {{name: string, email: string, message: string}} values
|
||||
* @param {string} [to]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildContactMailto(values, to = MAILTO_TARGET) {
|
||||
const subject = `Contact form submission from ${values.name || "someone"}`;
|
||||
const body = [
|
||||
`Name: ${values.name || ""}`,
|
||||
`Email: ${values.email || ""}`,
|
||||
"",
|
||||
"Message:",
|
||||
values.message || "",
|
||||
].join("\n");
|
||||
return buildMailtoUrl({ to, subject, body });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a contact form submit: build the mailto: URL and send the
|
||||
* visitor to it so their mail client opens pre-filled.
|
||||
*
|
||||
* @param {HTMLFormElement} form
|
||||
* @param {{to?: string, navigate?: (url: string) => void}} [options]
|
||||
* @returns {string} the mailto: URL that was navigated to
|
||||
*/
|
||||
export function handleContactSubmit(
|
||||
form,
|
||||
{ to = MAILTO_TARGET, navigate = (url) => { window.location.href = url; } } = {}
|
||||
) {
|
||||
const url = buildContactMailto(readFormValues(form), to);
|
||||
navigate(url);
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the submit handler onto a contact form.
|
||||
* @param {HTMLFormElement} form
|
||||
*/
|
||||
export function initContactForm(form) {
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
handleContactSubmit(form);
|
||||
});
|
||||
}
|
||||
|
||||
// Browser-only wiring — guarded so this module stays importable in Node tests.
|
||||
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
||||
const form = document.getElementById("contact-form");
|
||||
if (form) initContactForm(form);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Build a `mailto:` URL from an address, subject, and body.
|
||||
* Subject and body are URL-encoded so the visitor's mail client
|
||||
* opens pre-filled with the submitted values.
|
||||
*
|
||||
* @param {{to?: string, subject?: string, body?: string}} options
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildMailtoUrl({ to = "", subject = "", body = "" } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (subject) params.set("subject", subject);
|
||||
if (body) params.set("body", body);
|
||||
const query = params.toString();
|
||||
return query ? `mailto:${to}?${query}` : `mailto:${to}`;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
MAILTO_TARGET,
|
||||
buildContactMailto,
|
||||
handleContactSubmit,
|
||||
readFormValues,
|
||||
} from "../js/contact.js";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const contactHtml = readFileSync(join(root, "contact.html"), "utf8");
|
||||
const indexHtml = readFileSync(join(root, "index.html"), "utf8");
|
||||
|
||||
/** Minimal stand-in for a DOM form (has querySelectorAll("[name]")). */
|
||||
function fakeForm(values) {
|
||||
return {
|
||||
querySelectorAll(selector) {
|
||||
assert.equal(selector, "[name]");
|
||||
return Object.entries(values).map(([name, value]) => ({ name, value }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component tests: the contact page renders the three required fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("contact page renders a form with name, email, and message fields", () => {
|
||||
assert.match(contactHtml, /<form[^>]*id="contact-form"/);
|
||||
assert.match(contactHtml, /<input[^>]*type="text"[^>]*id="name"[^>]*name="name"/);
|
||||
assert.match(contactHtml, /<input[^>]*type="email"[^>]*id="email"[^>]*name="email"/);
|
||||
assert.match(contactHtml, /<textarea[^>]*id="message"[^>]*name="message"/);
|
||||
});
|
||||
|
||||
test("all three contact form fields are required", () => {
|
||||
assert.match(contactHtml, /<input[^>]*id="name"[^>]*required/);
|
||||
assert.match(contactHtml, /<input[^>]*id="email"[^>]*required/);
|
||||
assert.match(contactHtml, /<textarea[^>]*id="message"[^>]*required/);
|
||||
});
|
||||
|
||||
test("contact page loads the module that wires form submission", () => {
|
||||
assert.match(contactHtml, /<script[^>]*type="module"[^>]*src="js\/contact\.js"/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component tests: the page is linked from site navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("home page nav links to the contact page", () => {
|
||||
assert.match(indexHtml, /<a href="contact\.html">Contact<\/a>/);
|
||||
assert.match(indexHtml, /class="nav-links"/);
|
||||
});
|
||||
|
||||
test("contact page nav marks the contact link as the current page", () => {
|
||||
assert.match(contactHtml, /<a href="contact\.html"[^>]*aria-current="page"[^>]*>Contact<\/a>/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests: submitting the form opens the mail client pre-filled
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("buildContactMailto pre-fills subject and body from entered values", () => {
|
||||
const url = buildContactMailto(
|
||||
{ name: "Ada Lovelace", email: "ada@example.com", message: "Hi there!" },
|
||||
"owner@example.com"
|
||||
);
|
||||
|
||||
assert.ok(url.startsWith("mailto:owner@example.com?"));
|
||||
|
||||
const params = new URL(url).searchParams;
|
||||
assert.equal(params.get("subject"), "Contact form submission from Ada Lovelace");
|
||||
assert.match(params.get("body"), /Name: Ada Lovelace/);
|
||||
assert.match(params.get("body"), /Email: ada@example\.com/);
|
||||
assert.match(params.get("body"), /Hi there!/);
|
||||
});
|
||||
|
||||
test("buildContactMailto defaults to the configured mailto target", () => {
|
||||
const url = buildContactMailto({ name: "A", email: "a@b.c", message: "m" });
|
||||
assert.ok(url.startsWith(`mailto:${MAILTO_TARGET}?`));
|
||||
});
|
||||
|
||||
test("readFormValues collects name, email, and message", () => {
|
||||
const values = readFormValues(fakeForm({ name: "N", email: "E", message: "M" }));
|
||||
assert.deepEqual(values, { name: "N", email: "E", message: "M" });
|
||||
});
|
||||
|
||||
test("handleContactSubmit navigates the visitor to the built mailto URL", () => {
|
||||
const form = fakeForm({
|
||||
name: "Grace Hopper",
|
||||
email: "grace@example.com",
|
||||
message: "Hello!",
|
||||
});
|
||||
const navigated = [];
|
||||
|
||||
const url = handleContactSubmit(form, {
|
||||
to: "owner@example.com",
|
||||
navigate: (target) => navigated.push(target),
|
||||
});
|
||||
|
||||
assert.equal(navigated.length, 1);
|
||||
assert.ok(navigated[0].startsWith("mailto:owner@example.com?"));
|
||||
assert.equal(navigated[0], url);
|
||||
|
||||
const params = new URL(navigated[0]).searchParams;
|
||||
assert.match(params.get("body"), /Grace Hopper/);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildMailtoUrl } from "../js/mailto.js";
|
||||
|
||||
test("builds a plain mailto URL when there is no subject or body", () => {
|
||||
assert.equal(buildMailtoUrl({ to: "ada@example.com" }), "mailto:ada@example.com");
|
||||
});
|
||||
|
||||
test("includes subject and body as URL-encoded query parameters", () => {
|
||||
const url = buildMailtoUrl({
|
||||
to: "owner@example.com",
|
||||
subject: "Hello, Ada!",
|
||||
body: "Line one\nLine two & <more>",
|
||||
});
|
||||
|
||||
assert.ok(url.startsWith("mailto:owner@example.com?"));
|
||||
|
||||
const params = new URL(url).searchParams;
|
||||
assert.equal(params.get("subject"), "Hello, Ada!");
|
||||
assert.equal(params.get("body"), "Line one\nLine two & <more>");
|
||||
});
|
||||
|
||||
test("omits empty subject and body", () => {
|
||||
const url = buildMailtoUrl({ to: "owner@example.com", subject: "", body: "" });
|
||||
assert.equal(url, "mailto:owner@example.com");
|
||||
});
|
||||
|
||||
test("encodes spaces and special characters so the mail client opens pre-filled", () => {
|
||||
const url = buildMailtoUrl({ to: "o@example.com", subject: "a b", body: "x&y=z" });
|
||||
const params = new URL(url).searchParams;
|
||||
assert.equal(params.get("subject"), "a b");
|
||||
assert.equal(params.get("body"), "x&y=z");
|
||||
});
|
||||
Reference in New Issue
Block a user