Plan Your Visit landing page for visit.waterschurch.org

Next.js 16 static export + PHP 8 API for cPanel/Apache hosting.

- Location-aware campus finder (IP estimate, optional precise location, ZIP search)
- Three-step Plan Your Visit form with separate email/SMS consent
- PHP API: validation, anti-spam, rate limiting, SQLite lead store,
  Mailchimp and Sinch Engage adapters, idempotent retries via cron
- Attribution capture (utm_*, gclid/gbraid/wbraid, fbclid, ttclid, twclid) and
  consent-aware analytics hooks
- Vitest and PHP test suites, cPanel deployment script and docs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-22 13:22:26 -04:00
co-authored by Claude Fable 5.1
commit 78cd0cc550
109 changed files with 16861 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PlanVisitForm } from "@/components/PlanVisitForm";
import { VisitProvider } from "@/components/VisitProvider";
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
}
const ui = () =>
render(
<VisitProvider>
<PlanVisitForm />
</VisitProvider>,
);
describe("PlanVisitForm (keyboard-operable, consent-enforcing, server-confirmed)", () => {
const fetchMock = vi.fn<typeof fetch>();
beforeEach(() => {
window.sessionStorage.clear();
window.dataLayer = [];
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockReset();
// IntersectionObserver is used by the floating button; not needed here but keep jsdom quiet
vi.stubGlobal("IntersectionObserver", class { observe() {} disconnect() {} });
});
afterEach(() => vi.unstubAllGlobals());
it("walks through all three steps with the keyboard and submits a valid lead", async () => {
const user = userEvent.setup();
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, submissionId: "x", email: { status: "mock" }, sms: { status: "mock" } }));
ui();
// Step 1: campus (radio group)
const norwood = screen.getByRole("radio", { name: /norwood/i });
await user.click(norwood);
await user.click(screen.getByRole("button", { name: /continue/i }));
// Step 2: about you
const first = await screen.findByLabelText(/first name/i);
await user.type(first, "Jordan");
await user.type(screen.getByRole("textbox", { name: /^email/i }), "jordan@example.com");
await user.type(screen.getByRole("textbox", { name: /mobile phone/i }), "508-555-0142");
// consent boxes start unchecked and are separate
const emailBox = screen.getByRole("checkbox", { name: /email me/i });
const smsBox = screen.getByRole("checkbox", { name: /text me/i });
expect(emailBox).not.toBeChecked();
expect(smsBox).not.toBeChecked();
await user.click(emailBox);
await user.click(smsBox);
await user.click(screen.getByRole("button", { name: /continue/i }));
// Step 3: visit details (all optional) → submit
const submit = await screen.findByRole("button", { name: /send my visit details/i });
submit.focus();
await user.keyboard("{Enter}");
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toMatch(/\/api\/plan-visit\.php$/);
const body = JSON.parse(String(init?.body));
expect(body.campusId).toBe("norwood");
expect(body.firstName).toBe("Jordan");
expect(body.smsConsent).toBe(true);
expect(body.emailConsent).toBe(true);
expect(body.consentVersion).toMatch(/^\d{4}-\d{2}-\d{2}/);
expect(body.submissionId).toMatch(/^[0-9a-f-]{36}$/);
expect(body.website).toBe("");
// Confirmation preserves the page design and shows campus details + Text Us
await screen.findByText(/you're all set/i);
expect(screen.getByText(/668 Walpole Street/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: /text us a question/i })).toHaveAttribute("href", expect.stringMatching(/^sms:\+17817623357/));
// Conversion fired only after server success
const events = (window.dataLayer as { event: string }[]).map((e) => e.event);
expect(events).toContain("plan_visit_submitted");
expect(events.indexOf("plan_visit_success")).toBeGreaterThan(events.indexOf("plan_visit_submitted"));
});
it("blocks SMS consent without a phone number and announces the error", async () => {
const user = userEvent.setup();
ui();
await user.click(screen.getByRole("radio", { name: /north attleboro/i }));
await user.click(screen.getByRole("button", { name: /continue/i }));
await user.type(await screen.findByLabelText(/first name/i), "Sam");
await user.type(screen.getByRole("textbox", { name: /^email/i }), "sam@example.com");
await user.click(screen.getByRole("checkbox", { name: /text me/i }));
await user.click(screen.getByRole("button", { name: /continue/i }));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(/mobile number/i);
expect(screen.getByRole("textbox", { name: /mobile phone/i })).toHaveAttribute("aria-invalid", "true");
expect(fetchMock).not.toHaveBeenCalled();
});
it("keeps the visitor's answers and shows a friendly message when the server fails", async () => {
const user = userEvent.setup();
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: false, error: "Something went wrong on our end. Please try again in a moment." }, 500));
ui();
await user.click(screen.getByRole("radio", { name: /woonsocket/i }));
await user.click(screen.getByRole("button", { name: /continue/i }));
await user.type(await screen.findByLabelText(/first name/i), "Alex");
await user.type(screen.getByRole("textbox", { name: /^email/i }), "alex@example.com");
await user.click(screen.getByRole("button", { name: /continue/i }));
await user.click(await screen.findByRole("button", { name: /send my visit details/i }));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(/try again/i);
// answers preserved
await user.click(screen.getByRole("button", { name: /back/i }));
expect(await screen.findByLabelText(/first name/i)).toHaveValue("Alex");
});
it("maps server field errors back to the right step", async () => {
const user = userEvent.setup();
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: false, error: "Please check the highlighted fields.", fieldErrors: { email: "That email doesn't look right." } }, 422));
ui();
await user.click(screen.getByRole("radio", { name: /southcoast/i }));
await user.click(screen.getByRole("button", { name: /continue/i }));
await user.type(await screen.findByLabelText(/first name/i), "Riley");
await user.type(screen.getByRole("textbox", { name: /^email/i }), "riley@example.com");
await user.click(screen.getByRole("button", { name: /continue/i }));
await user.click(await screen.findByRole("button", { name: /send my visit details/i }));
const email = await screen.findByRole("textbox", { name: /^email/i });
expect(email).toHaveAttribute("aria-invalid", "true");
expect(within(email.closest("div")!).getByRole("alert")).toHaveTextContent(/email/i);
});
});