Compare commits
3
Commits
8a4c4bccec
...
55add6ba17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55add6ba17 | ||
|
|
120ffb7445 | ||
|
|
0f769d7bdd |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Status legend: `[ ]` open · `[x]` done · `[?]` needs verification · 🛑 owne
|
|||||||
| `npm run build` | green, 21 pages, 0 errors / 0 warnings |
|
| `npm run build` | green, 21 pages, 0 errors / 0 warnings |
|
||||||
| `npm run typecheck` | green, 28 files, 0/0/0 |
|
| `npm run typecheck` | green, 28 files, 0/0/0 |
|
||||||
| `npm run seo:lint` | green, 21 pages clean |
|
| `npm run seo:lint` | green, 21 pages clean |
|
||||||
| `npm run test:e2e` | **42 passed / 4 failed** (stale assertions, see §2.1) |
|
| `npm run test:e2e` | **44 passed / 4 failed** (the 4 are stale assertions, see §2.1) |
|
||||||
| CI root cause | **found + fixed in-repo 2026-09-22** — see §1.2; needs a green run to confirm |
|
| CI root cause | **found + fixed in-repo 2026-09-22** — see §1.2; needs a green run to confirm |
|
||||||
| Staging URL | **https://bcs.int.7tl-homelab.com** — LAN-only, valid cert (§2.6) |
|
| Staging URL | **https://bcs.int.7tl-homelab.com** — LAN-only, valid cert (§2.6) |
|
||||||
| Old WordPress site | untouched and serving — must stay that way until cutover |
|
| Old WordPress site | untouched and serving — must stay that way until cutover |
|
||||||
@@ -124,12 +124,40 @@ Two notes written for developers, not visitors, were being rendered:
|
|||||||
Replaced the first with reader-facing copy and dropped the second (the deviation it described is
|
Replaced the first with reader-facing copy and dropped the second (the deviation it described is
|
||||||
already recorded in `docs/content-deviations.md`). Commit `4e354d2`.
|
already recorded in `docs/content-deviations.md`). Commit `4e354d2`.
|
||||||
|
|
||||||
### 1.4 Contact form has no endpoint 🛑
|
### 1.4 Contact form endpoint — plumbing DONE 2026-09-23, needs your Formspree ID 🛑
|
||||||
`PUBLIC_CONTACT_FORM_ENDPOINT` is `""` in `k8s/configmap.yaml`, so the form falls back to a
|
**Correction to the original entry:** it said to set `PUBLIC_CONTACT_FORM_ENDPOINT` in
|
||||||
mailto handoff. Inbound leads will be lost or awkward on day one.
|
`k8s/configmap.yaml`. That would never have worked.
|
||||||
- [ ] Choose an endpoint (Formspree / webhook / ntfy / self-hosted) and set it in the ConfigMap.
|
- The Deployment has **no `env`/`envFrom`** referencing that ConfigMap — it is dead config.
|
||||||
- [ ] Test a real submission end-to-end and confirm `/thank-you/` is reached.
|
(`PUBLIC_SITE_URL` is in the same state; it only appears to work because the default baked into
|
||||||
- [ ] Confirm the delivery address is monitored.
|
`astro.config.mjs` is already correct.)
|
||||||
|
- Even wired up it could not work: `output: "static"` means Astro **inlines** `PUBLIC_*` at image
|
||||||
|
build time, and the pod only runs nginx over pre-built HTML. There is no runtime knob.
|
||||||
|
|
||||||
|
**Done (commit `af4cc4a`):**
|
||||||
|
- [x] `Dockerfile` takes `ARG PUBLIC_CONTACT_FORM_ENDPOINT` / `PUBLIC_SITE_URL` → `ENV` before build.
|
||||||
|
- [x] CI passes `--build-arg` from a `CONTACT_FORM_ENDPOINT` Actions secret.
|
||||||
|
- [x] ConfigMap rewritten as documentation, with a warning not to add `envFrom`.
|
||||||
|
- [x] Submit posts in the background via `fetch()` and redirects to our own `/thank-you/` instead
|
||||||
|
of navigating to the provider's page.
|
||||||
|
- [x] Failure path: stays on the page, re-enables the button, shows the phone/email fallback.
|
||||||
|
- [x] Honeypot `_gotcha` (Formspree treats it as spam), positioned off-screen rather than
|
||||||
|
`display:none` so bots that skip hidden fields still fill it.
|
||||||
|
- [x] e2e covers the real fetch path (mocked endpoint, asserts the payload) and the failure case.
|
||||||
|
|
||||||
|
**Still needs you:**
|
||||||
|
- [ ] 🛑 Create the Formspree form for **info@bluecapstrategies.com** and confirm the address.
|
||||||
|
- [ ] 🛑 Add the endpoint as a Gitea repo Actions secret named **`CONTACT_FORM_ENDPOINT`**
|
||||||
|
(Settings → Actions → Secrets on `bmr_bluecap/bcs-website`), value
|
||||||
|
`https://formspree.io/f/<your-id>`.
|
||||||
|
- [ ] Push any commit touching `src/**` so CI rebuilds with the secret baked in.
|
||||||
|
- [ ] Verify on the staging URL: submit a real inquiry, confirm it arrives, confirm the browser
|
||||||
|
lands on `/thank-you/`.
|
||||||
|
- [ ] Confirm the destination inbox is actually monitored. A working form delivering into an
|
||||||
|
unwatched inbox is its own kind of failure.
|
||||||
|
|
||||||
|
**Degradation if the secret is missing:** the build still succeeds and the form falls back to
|
||||||
|
opening a `mailto:` draft. Survivable, but it fails silently for webmail users with no OS mail
|
||||||
|
client — so treat a missing secret as a launch blocker, not a warning.
|
||||||
|
|
||||||
### 1.5 Draft insight was being published and indexed ✅ FIXED 2026-09-23
|
### 1.5 Draft insight was being published and indexed ✅ FIXED 2026-09-23
|
||||||
`src/pages/insights/[slug].astro` called `getCollection("insights")` unfiltered, so the
|
`src/pages/insights/[slug].astro` called `getCollection("insights")` unfiltered, so the
|
||||||
|
|||||||
+10
-1
@@ -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: ""
|
||||||
+56
-6
@@ -34,7 +34,11 @@ const endpoint = import.meta.env.PUBLIC_CONTACT_FORM_ENDPOINT ?? "";
|
|||||||
Tell us about your project and we will follow up within two business days.
|
Tell us about your project and we will follow up within two business days.
|
||||||
</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" />
|
{/* `_subject` (underscore) is Formspree's reserved field for the notification subject
|
||||||
|
line. Without it the mail arrives under a generic "New submission" subject. */}
|
||||||
|
<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 +63,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>
|
||||||
@@ -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
@@ -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();
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user