feat(contact): wire the form to a real endpoint at build time

The contact form could not be made to work by configuration. Two reasons,
both fixed here.

The endpoint never reached the browser. k8s/configmap.yaml carried
PUBLIC_CONTACT_FORM_ENDPOINT, but the Deployment has no env/envFrom pointing
at that ConfigMap, so it was dead config. Wiring it up would not have helped
either: this is a static Astro build, so import.meta.env.PUBLIC_* is inlined
when the image is built and the pod only serves pre-built HTML. Anything the
browser needs has to arrive as a build arg. Add ARG/ENV to the Dockerfile and
pass --build-arg from CI, sourced from a CONTACT_FORM_ENDPOINT Actions
secret. Rewrite the ConfigMap as documentation of what the build expects,
with an explicit warning not to add envFrom.

Submission behaviour was also wrong for visitors. A native POST would have
navigated them to the form provider's page; post in the background with
fetch() and send them to our own /thank-you/ instead. On failure the form now
stays put, re-enables the button and offers the phone and email fallback,
rather than silently losing the inquiry. Add a honeypot (_gotcha, which
Formspree treats as spam) positioned off-screen rather than display:none, so
bots that skip hidden fields still fill it.

Read data-endpoint at submit time rather than caching it at init, keeping the
attribute the single source of truth.

Tests: replace the old contact assertion, which faked a native GET
navigation, with one that mocks the endpoint and exercises the real fetch
path including the posted payload. Add a case for endpoint failure.

typecheck 0/0/0, build 0/0, seo:lint clean on 20 pages, e2e 44 passed.
The 4 remaining failures are the pre-existing stale nav assertions in
docs/launch-checklist.md section 2.1, untouched here.
This commit is contained in:
bmr_bluecap committed 2026-10-09 07:04:59 -04:00
1 parent 8a4c4bccec
commit 0f769d7bdd
6 files changed
+135 -12

No files matched your search

+8 -1
View File
@@ -94,7 +94,14 @@ jobs:
- name: Build & push image to Gitea Packages - name: Build & push image to Gitea Packages
run: | run: |
echo "${{ secrets.CI_TOKEN }}" | docker login 10.66.15.22:3000 -u "${{ github.repository_owner }}" --password-stdin echo "${{ secrets.CI_TOKEN }}" | docker login 10.66.15.22:3000 -u "${{ github.repository_owner }}" --password-stdin
docker build -t "$PUSH_IMAGE:${{ steps.tag.outputs.sha }}" -t "$PUSH_IMAGE:latest" . # PUBLIC_* values are inlined at build time (static Astro) — they cannot be supplied at
# deploy time. CONTACT_FORM_ENDPOINT is the Formspree URL; set it as a repo Actions
# secret. If it is empty the build still succeeds and the contact form degrades to a
# mailto draft, so a missing secret is survivable but should be caught in review.
docker build \
--build-arg PUBLIC_CONTACT_FORM_ENDPOINT="${{ secrets.CONTACT_FORM_ENDPOINT }}" \
--build-arg PUBLIC_SITE_URL="https://www.bluecapstrategies.com" \
-t "$PUSH_IMAGE:${{ steps.tag.outputs.sha }}" -t "$PUSH_IMAGE:latest" .
# Registry pushes can time out on a slow blob; docker push is resumable, so retry. # Registry pushes can time out on a slow blob; docker push is resumable, so retry.
push_retry() { push_retry() {
for i in 1 2 3 4 5; do for i in 1 2 3 4 5; do
+8
View File
@@ -6,6 +6,14 @@ RUN npm ci
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
# Astro is a STATIC build: import.meta.env.PUBLIC_* is inlined into the HTML here, at image build
# time. A runtime env var on the pod can never reach it (the pod only runs nginx over pre-built
# files), so anything the browser needs must arrive as a build arg. CI passes these; see
# .gitea/workflows/ci.yml.
ARG PUBLIC_CONTACT_FORM_ENDPOINT=""
ARG PUBLIC_SITE_URL="https://www.bluecapstrategies.com"
ENV PUBLIC_CONTACT_FORM_ENDPOINT=$PUBLIC_CONTACT_FORM_ENDPOINT \
PUBLIC_SITE_URL=$PUBLIC_SITE_URL
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npm run build RUN npm run build
+10 -1
View File
@@ -1,9 +1,18 @@
# NOTE: this ConfigMap is NOT consumed by the Deployment, and wiring it up would not help.
# The site is a STATIC Astro build served by nginx — every PUBLIC_* value is inlined into the HTML
# when the image is built, so a runtime env var cannot change it. Configure these as build args in
# .gitea/workflows/ci.yml instead (PUBLIC_CONTACT_FORM_ENDPOINT comes from the CONTACT_FORM_ENDPOINT
# Actions secret).
#
# Kept as documentation of the values the build expects. Do not add envFrom pointing at it — that
# would imply a runtime knob that does not exist.
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap
metadata: metadata:
name: bluecap-website-config name: bluecap-website-config
namespace: bluecap-strategies namespace: bluecap-strategies
data: data:
# Mirrors the build arg default in the Dockerfile.
PUBLIC_SITE_URL: "https://www.bluecapstrategies.com" PUBLIC_SITE_URL: "https://www.bluecapstrategies.com"
# Set to a Formspree, webhook, ntfy, or self-hosted form endpoint before production cutover. # Real value lives in the CONTACT_FORM_ENDPOINT Actions secret, injected at image build.
PUBLIC_CONTACT_FORM_ENDPOINT: "" PUBLIC_CONTACT_FORM_ENDPOINT: ""
+53 -5
View File
@@ -35,6 +35,8 @@ const endpoint = import.meta.env.PUBLIC_CONTACT_FORM_ENDPOINT ?? "";
</p> </p>
<form class="contact-form" method="post" action={endpoint || "/thank-you/"} data-contact-form data-endpoint={endpoint}> <form class="contact-form" method="post" action={endpoint || "/thank-you/"} data-contact-form data-endpoint={endpoint}>
<input type="hidden" name="subject" value="BlueCap Strategies website inquiry" /> <input type="hidden" name="subject" value="BlueCap Strategies website inquiry" />
{/* Formspree treats a filled _gotcha field as spam. Hidden from people, not from bots. */}
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" aria-hidden="true" class="gotcha" />
<div class="field-grid"> <div class="field-grid">
<label> <label>
Name * Name *
@@ -59,23 +61,69 @@ const endpoint = import.meta.env.PUBLIC_CONTACT_FORM_ENDPOINT ?? "";
Message * Message *
<textarea name="message" required minlength="20"></textarea> <textarea name="message" required minlength="20"></textarea>
</label> </label>
<button class="button" type="submit">Submit</button> <button class="button" type="submit" data-submit>Submit</button>
<p class="form-status" data-form-status role="status" aria-live="polite" hidden></p>
</form> </form>
</div> </div>
</section> </section>
<script> <script>
const form = document.querySelector("[data-contact-form]"); const form = document.querySelector("[data-contact-form]");
form?.addEventListener("submit", (event) => {
const endpoint = form.getAttribute("data-endpoint"); if (form instanceof HTMLFormElement) {
if (endpoint) return; const status = form.querySelector("[data-form-status]");
const submit = form.querySelector("[data-submit]");
const say = (message: string, isError: boolean) => {
if (!(status instanceof HTMLElement)) return;
status.textContent = message;
status.classList.toggle("is-error", isError);
status.hidden = false;
};
form.addEventListener("submit", async (event) => {
if (!form.reportValidity()) return;
// Read at submit time, not at init, so the attribute stays the single source of truth.
const endpoint = form.getAttribute("data-endpoint") ?? "";
// No endpoint configured (local dev, or a build that never received the build arg):
// fall back to opening a mail draft rather than silently posting nowhere.
if (!endpoint) {
event.preventDefault(); event.preventDefault();
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
const data = new FormData(form); const data = new FormData(form);
const subject = encodeURIComponent("BlueCap Strategies website inquiry"); const subject = encodeURIComponent("BlueCap Strategies website inquiry");
const body = encodeURIComponent( const body = encodeURIComponent(
`Name: ${data.get("name")}\nOrganization: ${data.get("organization") ?? ""}\nEmail: ${data.get("email")}\nPhone: ${data.get("phone") ?? ""}\n\n${data.get("message")}`, `Name: ${data.get("name")}\nOrganization: ${data.get("organization") ?? ""}\nEmail: ${data.get("email")}\nPhone: ${data.get("phone") ?? ""}\n\n${data.get("message")}`,
); );
window.location.href = `mailto:${"info@bluecapstrategies.com"}?subject=${subject}&body=${body}`; window.location.href = `mailto:${"info@bluecapstrategies.com"}?subject=${subject}&body=${body}`;
return;
}
// Post in the background so the visitor lands on our own /thank-you/ page rather than
// the form provider's. Native POST would navigate away to Formspree.
event.preventDefault();
if (submit instanceof HTMLButtonElement) submit.disabled = true;
say("Sending…", false);
try {
const response = await fetch(endpoint, {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
}); });
if (response.ok) {
window.location.href = "/thank-you/";
return;
}
throw new Error(`Form endpoint returned ${response.status}`);
} catch {
if (submit instanceof HTMLButtonElement) submit.disabled = false;
say(
"Sorry — that didn't send. Please email info@bluecapstrategies.com or call 804-332-6618.",
true,
);
}
});
}
</script> </script>
</BaseLayout> </BaseLayout>
+21
View File
@@ -1078,6 +1078,27 @@ textarea:focus {
font-size: 0.95rem; font-size: 0.95rem;
} }
/* Honeypot: kept in the layout but out of sight and out of the tab order. Not display:none —
some bots skip hidden fields, which would defeat the point. */
.gotcha {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.form-status {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
color: var(--muted);
}
.form-status.is-error {
color: #b3261e;
}
.site-footer { .site-footer {
background: #10293d; background: #10293d;
color: rgba(255, 255, 255, 0.78); color: rgba(255, 255, 255, 0.78);
+35 -5
View File
@@ -123,12 +123,42 @@ test("contact form validates required, invalid, and valid paths", async ({ page
await page.locator('input[name="email"]').fill("test@example.com"); await page.locator('input[name="email"]').fill("test@example.com");
await page.locator('textarea[name="message"]').fill("This is a valid test inquiry for BlueCap Strategies."); await page.locator('textarea[name="message"]').fill("This is a valid test inquiry for BlueCap Strategies.");
await page.locator("form").evaluate((form) => {
form.setAttribute("data-endpoint", "/thank-you/"); // Exercise the real submit path: the form POSTs to the configured endpoint in the background
form.setAttribute("action", "/thank-you/"); // and only then sends the visitor to our own /thank-you/. Mock the endpoint so the test never
form.setAttribute("method", "get"); // touches the live form provider.
const endpoint = "https://forms.test.invalid/f/mock";
let posted: string | null = null;
await page.route(endpoint, async (route) => {
posted = route.request().postData();
await route.fulfill({ status: 200, contentType: "application/json", body: '{"ok":true}' });
}); });
await page.locator("form").evaluate((form, url) => {
form.setAttribute("data-endpoint", url);
}, endpoint);
await submit.click(); await submit.click();
await expect(page).toHaveURL(/\/thank-you\/(?:\?.*)?$/); await expect(page).toHaveURL(/\/thank-you\/$/);
await expect(page.getByRole("heading", { name: "Thank you" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Thank you" })).toBeVisible();
expect(posted, "form fields should reach the endpoint").toContain("test@example.com");
});
test("contact form surfaces an error when the endpoint fails", async ({ page }) => {
await page.goto("/contact/");
const endpoint = "https://forms.test.invalid/f/mock";
await page.route(endpoint, (route) => route.fulfill({ status: 500, body: "nope" }));
await page.locator("form").evaluate((form, url) => {
form.setAttribute("data-endpoint", url);
}, endpoint);
await page.locator('input[name="name"]').fill("Test User");
await page.locator('input[name="email"]').fill("test@example.com");
await page.locator('textarea[name="message"]').fill("This is a valid test inquiry for BlueCap Strategies.");
await page.getByRole("button", { name: "Submit" }).click();
// A failed send must not strand the visitor: it stays on the page, says so, and offers the
// phone/email fallback rather than silently losing the inquiry.
await expect(page).toHaveURL(/\/contact\/$/);
await expect(page.locator("[data-form-status]")).toContainText("didn't send");
await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled();
}); });