From 0f769d7bddeaf3e1639c9726d9be0d29bfcf191f Mon Sep 17 00:00:00 2001 From: Brad Rodgers Date: Wed, 23 Sep 2026 10:05:55 -0400 Subject: [PATCH] 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. --- .gitea/workflows/ci.yml | 9 ++++- Dockerfile | 8 +++++ k8s/configmap.yaml | 11 +++++- src/pages/contact.astro | 74 +++++++++++++++++++++++++++++++++-------- src/styles/global.css | 21 ++++++++++++ tests/site.spec.ts | 40 +++++++++++++++++++--- 6 files changed, 143 insertions(+), 20 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7a20c09..98d0d31 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -94,7 +94,14 @@ jobs: - name: Build & push image to Gitea Packages run: | 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. push_retry() { for i in 1 2 3 4 5; do diff --git a/Dockerfile b/Dockerfile index 00b0f59..5f20126 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,14 @@ RUN npm ci FROM node:22-alpine AS build 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 . . RUN npm run build diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml index cd486aa..14b1d61 100644 --- a/k8s/configmap.yaml +++ b/k8s/configmap.yaml @@ -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 kind: ConfigMap metadata: name: bluecap-website-config namespace: bluecap-strategies data: + # Mirrors the build arg default in the Dockerfile. 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: "" diff --git a/src/pages/contact.astro b/src/pages/contact.astro index 73f9eab..50f4222 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -35,6 +35,8 @@ const endpoint = import.meta.env.PUBLIC_CONTACT_FORM_ENDPOINT ?? "";

+ {/* Formspree treats a filled _gotcha field as spam. Hidden from people, not from bots. */} +
- + +
diff --git a/src/styles/global.css b/src/styles/global.css index 6649046..0702fef 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1078,6 +1078,27 @@ textarea:focus { 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 { background: #10293d; color: rgba(255, 255, 255, 0.78); diff --git a/tests/site.spec.ts b/tests/site.spec.ts index ff96510..b19edf8 100644 --- a/tests/site.spec.ts +++ b/tests/site.spec.ts @@ -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('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/"); - form.setAttribute("action", "/thank-you/"); - form.setAttribute("method", "get"); + + // Exercise the real submit path: the form POSTs to the configured endpoint in the background + // and only then sends the visitor to our own /thank-you/. Mock the endpoint so the test never + // 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 expect(page).toHaveURL(/\/thank-you\/(?:\?.*)?$/); + await expect(page).toHaveURL(/\/thank-you\/$/); 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(); });