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
+200
View File
@@ -0,0 +1,200 @@
@import "tailwindcss";
/* ---------------------------------------------------------------------------
Waters Church · visit.waterschurch.org design tokens
Restrained palette: near-black, bone, white, and the brand teal.
--------------------------------------------------------------------------- */
@theme {
--color-ink: #0a0d10;
--color-ink-2: #14191e;
--color-ink-3: #1f262d;
--color-bone: #f4f2ec;
--color-bone-2: #e9e6dd;
--color-paper: #ffffff;
--color-teal: #3fb0bb;
--color-teal-bright: #62d7e1;
--color-teal-deep: #237a83;
--color-teal-ink: #0f3a3f;
--color-mist: #a9b3bc;
--color-slate: #4b5661;
--color-danger: #d64545;
--color-success: #2f9e6b;
--font-display: var(--font-montserrat), "Montserrat", ui-sans-serif, system-ui, sans-serif;
--font-sans: var(--font-inter), "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--radius-brand: 4px;
--shadow-lift: 0 10px 30px -12px rgb(0 0 0 / 0.35);
--shadow-teal: 0 12px 40px -14px rgb(63 176 187 / 0.7);
--animate-rise: rise 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) both;
--animate-fade: fade 0.5s ease-out both;
@keyframes rise {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
@layer base {
html {
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
background-color: var(--color-ink);
}
body {
@apply bg-ink text-paper font-sans antialiased;
font-feature-settings: "ss01", "cv11";
}
::selection {
background: var(--color-teal);
color: var(--color-ink);
}
/* Visible, high-contrast focus for keyboard users */
:focus-visible {
outline: 3px solid var(--color-teal-bright);
outline-offset: 3px;
border-radius: 2px;
}
.on-light :focus-visible,
.on-light :focus-visible {
outline-color: var(--color-teal-deep);
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
h1,
h2,
h3 {
@apply font-display font-extrabold tracking-[-0.03em];
text-wrap: balance;
}
p {
text-wrap: pretty;
}
input,
select,
textarea,
button {
font: inherit;
}
/* Prevent iOS zoom on focus */
input,
select,
textarea {
font-size: max(16px, 1em);
}
summary::-webkit-details-marker {
display: none;
}
input[type="date"] {
min-height: 3.25rem;
}
}
@layer components {
.display-xl {
@apply font-display font-black uppercase leading-[0.92] tracking-[-0.035em];
font-size: clamp(2.75rem, 11vw, 7rem);
}
.display-lg {
@apply font-display font-black uppercase leading-[0.95] tracking-[-0.03em];
font-size: clamp(2.25rem, 7vw, 4.5rem);
}
.display-md {
@apply font-display font-extrabold uppercase leading-[1] tracking-[-0.02em];
font-size: clamp(1.5rem, 4.5vw, 2.5rem);
}
.eyebrow {
@apply font-display text-xs font-bold uppercase tracking-[0.22em] text-teal;
}
.btn {
@apply inline-flex min-h-14 items-center justify-center gap-2 rounded-brand px-6 font-display text-base font-bold uppercase tracking-wide transition-[transform,background-color,color,box-shadow] duration-200 select-none;
-webkit-tap-highlight-color: transparent;
}
.btn:active {
transform: translateY(1px) scale(0.99);
}
.btn-primary {
@apply bg-teal text-ink shadow-teal hover:bg-teal-bright;
}
.btn-secondary {
@apply bg-paper text-ink hover:bg-bone;
}
.btn-outline {
@apply border-2 border-paper/70 text-paper hover:border-paper hover:bg-paper/10;
}
.btn-outline-dark {
@apply border-2 border-ink/80 text-ink hover:bg-ink hover:text-paper;
}
.btn-ghost {
@apply text-paper underline-offset-4 hover:underline;
}
.btn[disabled],
.btn[aria-disabled="true"] {
@apply cursor-not-allowed opacity-60;
}
.field {
@apply block w-full rounded-brand border-2 border-ink/15 bg-paper px-4 py-3.5 text-ink placeholder:text-slate/70 transition-colors focus:border-teal-deep focus:outline-none;
min-height: 3.25rem;
}
.field[aria-invalid="true"] {
@apply border-danger;
}
.label {
@apply mb-1.5 block font-display text-xs font-bold uppercase tracking-[0.14em] text-slate;
}
.check {
@apply mt-0.5 size-6 shrink-0 cursor-pointer appearance-none rounded-[3px] border-2 border-ink/40 bg-paper transition-colors checked:border-teal-deep checked:bg-teal-deep;
background-image: none;
}
.check:checked {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3.5 8.5l3 3 6-7' stroke='white' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-size: 100% 100%;
}
.card {
@apply rounded-brand bg-paper text-ink shadow-lift;
}
.hairline {
@apply h-px w-full bg-paper/10;
}
.stripe {
background-image: repeating-linear-gradient(
-45deg,
var(--color-teal) 0 2px,
transparent 2px 10px
);
}
}
@utility text-balance {
text-wrap: balance;
}
@utility safe-bottom {
padding-bottom: max(1rem, env(safe-area-inset-bottom));
}
+73
View File
@@ -0,0 +1,73 @@
import type { Metadata, Viewport } from "next";
import { Inter, Montserrat } from "next/font/google";
import "./globals.css";
import { site } from "@/config/site";
import { JsonLd } from "@/components/JsonLd";
const montserrat = Montserrat({
subsets: ["latin"],
weight: ["700", "800", "900"],
variable: "--font-montserrat",
display: "swap",
});
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
const title = "Plan Your Visit · Waters Church";
const description =
"Your first visit to Waters Church doesn't have to feel uncertain. Find the location nearest you in Massachusetts, Rhode Island, or Florida, see what to expect, and plan your visit in about a minute.";
export const metadata: Metadata = {
metadataBase: new URL(site.url),
title,
description,
alternates: { canonical: "/" },
robots: site.robotsIndex ? { index: true, follow: true } : { index: false, follow: false, nocache: true },
openGraph: {
type: "website",
url: site.url,
siteName: site.name,
title,
description,
locale: "en_US",
images: [{ url: "/images/og-image.jpg", width: 1200, height: 630, alt: "Waters Church worship service" }],
},
twitter: { card: "summary_large_image", title, description, images: ["/images/og-image.jpg"] },
icons: {
icon: [{ url: "/images/favicon-32.png", sizes: "32x32", type: "image/png" }, { url: "/images/icon-512.png", sizes: "512x512", type: "image/png" }],
apple: "/images/apple-touch-icon.png",
},
applicationName: site.name,
formatDetection: { telephone: true },
};
export const viewport: Viewport = {
themeColor: "#0a0d10",
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${montserrat.variable} ${inter.variable}`}>
<head>
<link rel="preload" as="image" href="/images/hero-worship-1024.avif" type="image/avif" media="(max-width: 1024px)" />
<JsonLd />
</head>
<body>
<a
href="#main"
className="sr-only focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-[100] focus:rounded-brand focus:bg-teal focus:px-4 focus:py-2 focus:font-bold focus:text-ink"
>
Skip to content
</a>
{children}
</body>
</html>
);
}
+22
View File
@@ -0,0 +1,22 @@
import Link from "next/link";
import { logos } from "@/config/images";
import { site } from "@/config/site";
export default function NotFound() {
return (
<main className="flex min-h-svh flex-col items-center justify-center bg-ink px-6 text-center text-paper">
<img src={logos.markWhite} alt="" width={56} height={56} className="mb-8 size-14 opacity-80" />
<p className="eyebrow mb-3">404</p>
<h1 className="display-lg">That page moved on.</h1>
<p className="mt-4 max-w-md text-mist">The page you were looking for isn&apos;t here, but planning your first visit is one tap away.</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link href="/" className="btn btn-primary">
Plan My Visit
</Link>
<a href={site.mainSiteUrl} className="btn btn-outline">
waterschurch.org
</a>
</div>
</main>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { AnalyticsScripts } from "@/components/AnalyticsScripts";
import { CampusFinder } from "@/components/CampusFinder";
import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { Hero } from "@/components/Hero";
import { KidsSection } from "@/components/KidsSection";
import { PlanVisitForm } from "@/components/PlanVisitForm";
import { SocialProof } from "@/components/SocialProof";
import { FloatingTextUs } from "@/components/TextUsButton";
import { VisitProvider } from "@/components/VisitProvider";
import { WhatToExpect } from "@/components/WhatToExpect";
export default function Page() {
return (
<VisitProvider>
<Header />
<main id="main">
<Hero />
<CampusFinder />
<WhatToExpect />
<KidsSection />
<SocialProof />
<PlanVisitForm />
</main>
<Footer />
<FloatingTextUs />
<AnalyticsScripts />
</VisitProvider>
);
}
+11
View File
@@ -0,0 +1,11 @@
import type { MetadataRoute } from "next";
import { site } from "@/config/site";
export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots {
return {
rules: site.robotsIndex ? { userAgent: "*", allow: "/", disallow: ["/api/"] } : { userAgent: "*", disallow: "/" },
sitemap: `${site.url}/sitemap.xml`,
};
}
+8
View File
@@ -0,0 +1,8 @@
import type { MetadataRoute } from "next";
import { site } from "@/config/site";
export const dynamic = "force-static";
export default function sitemap(): MetadataRoute.Sitemap {
return [{ url: `${site.url}/`, changeFrequency: "monthly", priority: 1 }];
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useEffect, useState } from "react";
import { site } from "@/config/site";
import {
analyticsConsentDecided,
analyticsConsentGranted,
denyAnalyticsConsent,
grantAnalyticsConsent,
track,
} from "@/lib/analytics";
function inject(src: string, id: string, onload?: () => void) {
if (document.getElementById(id)) return;
const s = document.createElement("script");
s.id = id;
s.async = true;
s.src = src;
if (onload) s.onload = onload;
document.head.appendChild(s);
}
function loadVendors() {
const a = site.analytics;
window.dataLayer = window.dataLayer ?? [];
if (a.gtmId) {
window.dataLayer.push({ "gtm.start": Date.now(), event: "gtm.js" });
inject(`https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(a.gtmId)}`, "gtm-js");
}
if (a.ga4Id || a.googleAdsConversion) {
const id = a.ga4Id || a.googleAdsConversion.split("/")[0];
window.gtag =
window.gtag ||
function gtag() {
// eslint-disable-next-line prefer-rest-params
(window.dataLayer as unknown[]).push(arguments);
};
window.gtag("js", new Date());
if (a.ga4Id) window.gtag("config", a.ga4Id, { send_page_view: true });
if (a.googleAdsConversion) window.gtag("config", a.googleAdsConversion.split("/")[0]);
inject(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`, "gtag-js");
}
if (a.metaPixelId && !window.fbq) {
interface Fbq {
(...args: unknown[]): void;
queue: unknown[];
loaded: boolean;
version: string;
push: unknown;
callMethod?: (...args: unknown[]) => void;
}
const fbq = function (this: unknown, ...args: unknown[]) {
if (fbq.callMethod) fbq.callMethod(...args);
else fbq.queue.push(args);
} as Fbq;
fbq.queue = [];
fbq.loaded = true;
fbq.version = "2.0";
fbq.push = fbq;
window.fbq = fbq;
inject("https://connect.facebook.net/en_US/fbevents.js", "fb-pixel");
window.fbq("init", a.metaPixelId);
window.fbq("track", "PageView");
}
if (a.tiktokPixelId && !window.ttq) {
inject(`https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=${encodeURIComponent(a.tiktokPixelId)}&lib=ttq`, "tiktok-pixel", () => {
window.ttq?.page?.();
});
}
}
/**
* Loads only the vendors that are configured, respects the consent mode,
* and fires `landing_page_view` once.
*/
export function AnalyticsScripts() {
const [showBanner, setShowBanner] = useState(false);
useEffect(() => {
if (analyticsConsentGranted()) loadVendors();
// eslint-disable-next-line react-hooks/set-state-in-effect -- consent state lives in localStorage and is only knowable after hydration
else if (!analyticsConsentDecided()) setShowBanner(true);
track("landing_page_view");
}, []);
if (!showBanner) return null;
return (
<div
role="dialog"
aria-label="Analytics consent"
className="fixed inset-x-3 bottom-3 z-50 mx-auto max-w-lg rounded-brand bg-paper p-4 text-ink shadow-lift"
>
<p className="text-sm">
We use analytics to understand which ads bring people here. No personal details are shared with ad platforms.{" "}
<a href={site.privacyUrl} className="underline">
Privacy Policy
</a>
</p>
<div className="mt-3 flex gap-2">
<button
type="button"
className="btn btn-primary min-h-11 flex-1 text-sm"
onClick={() => {
grantAnalyticsConsent();
loadVendors();
setShowBanner(false);
}}
>
Allow
</button>
<button
type="button"
className="btn btn-outline-dark min-h-11 flex-1 text-sm"
onClick={() => {
denyAnalyticsConsent();
setShowBanner(false);
}}
>
Decline
</button>
</div>
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
"use client";
import { formatAddress, formatTimes, type Campus } from "@/config/campuses";
import { campusImageKey } from "@/config/images";
import { copy } from "@/content/copy";
import { formatDistance } from "@/lib/geo";
import { track } from "@/lib/analytics";
import { Picture } from "@/components/Picture";
import { TextUsLink } from "@/components/TextUsButton";
import { Icon } from "@/components/ui";
interface Props {
campus: Campus;
distanceMiles?: number;
badge?: string;
selected?: boolean;
onChoose: (id: string) => void;
onChange?: () => void;
/** Big feature layout vs. compact grid tile. */
variant?: "feature" | "tile";
}
export function CampusCard({ campus, distanceMiles, badge, selected, onChoose, onChange, variant = "tile" }: Props) {
const launching = campus.status === "launching";
const heading = `${campus.name}`;
const smsMessage = `Hi! I'm thinking about visiting Waters Church ${campus.name} and have a question.`;
if (variant === "feature") {
return (
<article
className={`card on-light grid overflow-hidden md:grid-cols-[1.1fr_1fr] ${selected ? "ring-4 ring-teal" : ""}`}
aria-labelledby={`campus-${campus.id}-title`}
>
<div className="relative aspect-[16/10] md:aspect-auto md:min-h-[22rem]">
<Picture
name={campusImageKey(campus.image)}
alt={`Waters Church ${campus.name}`}
sizes="(min-width: 768px) 50vw, 100vw"
fill
className="absolute inset-0"
/>
{badge && (
<span className="absolute top-4 left-4 inline-flex items-center gap-1.5 rounded-brand bg-ink/85 px-3 py-1.5 font-display text-xs font-bold uppercase tracking-wider text-teal-bright backdrop-blur">
<Icon name="target" className="size-3.5" /> {badge}
</span>
)}
</div>
<div className="flex flex-col p-6 sm:p-8">
<div className="flex items-start justify-between gap-4">
<div>
<p className="eyebrow text-teal-deep">{campus.address.state === "FL" ? "Florida" : campus.address.state === "RI" ? "Rhode Island" : "Massachusetts"}</p>
<h3 id={`campus-${campus.id}-title`} className="display-md mt-1">
{heading}
</h3>
</div>
{typeof distanceMiles === "number" && (
<p className="shrink-0 rounded-brand bg-bone px-3 py-2 text-center font-display text-sm font-bold text-ink">
{formatDistance(distanceMiles)}
<span className="block text-[10px] font-semibold tracking-wider text-slate uppercase">away</span>
</p>
)}
</div>
<address className="mt-4 whitespace-pre-line text-slate not-italic">{formatAddress(campus, true)}</address>
<ul className="mt-5 space-y-2">
{campus.serviceTimes.map((st) => (
<li key={st.day} className="flex items-start gap-2.5">
<Icon name="clock" className="mt-0.5 size-5 text-teal-deep" />
<div>
<span className="font-semibold text-ink">{st.day}</span>
<span className="text-slate"> · {formatTimes(st.times)}</span>
{st.note && <p className="text-sm text-slate">{st.note}</p>}
</div>
</li>
))}
</ul>
{campus.parkingNotes && <p className="mt-3 text-sm text-slate">{campus.parkingNotes}</p>}
<div className="mt-6 grid gap-2 sm:grid-cols-2">
{launching ? (
<a href={campus.campusUrl} className="btn btn-outline-dark sm:col-span-2">
Learn about the launch <Icon name="arrow" />
</a>
) : (
<button
type="button"
className={`btn ${selected ? "btn-outline-dark" : "btn-primary"} sm:col-span-2`}
onClick={() => onChoose(campus.id)}
aria-pressed={selected}
>
{selected ? (
<>
<Icon name="check" /> {copy.campus.chosen}
</>
) : (
<>
{copy.campus.choose} <Icon name="arrow" />
</>
)}
</button>
)}
<a
href={campus.directionsUrl}
target="_blank"
rel="noopener"
className="btn btn-outline-dark"
onClick={() => track("directions_clicked", { campus: campus.id, location: "campus_card" })}
>
<Icon name="pin" /> {copy.campus.directions}
</a>
{campus.smsNumber ? (
<TextUsLink number={campus.smsNumber} message={smsMessage} location="campus_card" className="btn btn-outline-dark">
<Icon name="sms" /> {copy.campus.textCampus}
</TextUsLink>
) : (
<a href={`mailto:${campus.email ?? ""}`} className="btn btn-outline-dark">
Email this campus
</a>
)}
</div>
{onChange && (
<button type="button" onClick={onChange} className="mt-4 self-start text-sm font-semibold text-teal-deep underline-offset-4 hover:underline">
{copy.campus.change}
</button>
)}
</div>
</article>
);
}
return (
<article
className={`card on-light group relative flex flex-col overflow-hidden transition-shadow ${selected ? "ring-4 ring-teal" : "hover:shadow-teal"}`}
aria-labelledby={`campus-tile-${campus.id}`}
>
<div className="relative aspect-[16/9]">
<Picture
name={campusImageKey(campus.image)}
alt=""
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 45vw, 100vw"
fill
className="absolute inset-0"
imgClassName="transition-transform duration-500 group-hover:scale-[1.03]"
/>
{launching && (
<span className="absolute top-3 left-3 rounded-brand bg-ink/85 px-2.5 py-1 font-display text-[11px] font-bold uppercase tracking-wider text-paper">
{copy.campus.launching}
</span>
)}
{typeof distanceMiles === "number" && !launching && (
<span className="absolute top-3 right-3 rounded-brand bg-paper/95 px-2.5 py-1 font-display text-xs font-bold text-ink">
{formatDistance(distanceMiles)}
</span>
)}
</div>
<div className="flex flex-1 flex-col p-5">
<h3 id={`campus-tile-${campus.id}`} className="font-display text-xl font-extrabold tracking-tight uppercase">
{campus.label}
</h3>
<p className="mt-1 text-sm text-slate">
{campus.address.line1}, {campus.address.city}
</p>
<p className="mt-3 text-sm">
{campus.serviceTimes.map((st) => (
<span key={st.day} className="block">
<span className="font-semibold">{st.day}</span> <span className="text-slate">{formatTimes(st.times)}</span>
</span>
))}
</p>
<div className="mt-auto pt-5">
{launching ? (
<a href={campus.campusUrl} className="btn btn-outline-dark min-h-12 w-full text-sm">
Learn more
</a>
) : (
<button
type="button"
className={`btn min-h-12 w-full text-sm ${selected ? "btn-outline-dark" : "btn-primary"}`}
onClick={() => onChoose(campus.id)}
aria-pressed={selected}
>
{selected ? (
<>
<Icon name="check" className="size-4" /> Selected
</>
) : (
copy.campus.choose
)}
</button>
)}
</div>
</div>
</article>
);
}
+231
View File
@@ -0,0 +1,231 @@
"use client";
import { useCallback, useEffect, useId, useRef, useState, type FormEvent } from "react";
import { campuses, openCampuses } from "@/config/campuses";
import { consent } from "@/content/consent";
import { copy } from "@/content/copy";
import { geocodeZip, locateApprox } from "@/lib/api";
import { track } from "@/lib/analytics";
import { FAR_AWAY_MILES, isValidLatLng, nearestCampus, normalizeZip, rankCampuses, type LatLng } from "@/lib/geo";
import { CampusCard } from "@/components/CampusCard";
import { useVisit } from "@/components/VisitProvider";
import { Container, FieldError, Icon, SectionHeading, Spinner } from "@/components/ui";
type LocateState = "idle" | "approx-loading" | "approx-done" | "approx-failed" | "precise-loading" | "precise-denied" | "precise-unsupported" | "precise-done" | "zip-loading" | "zip-done" | "zip-failed";
export function CampusFinder() {
const { selectedCampus, selectCampus, clearCampus, recommendation, setRecommendation, origin } = useVisit();
const [state, setState] = useState<LocateState>("idle");
const [zip, setZip] = useState("");
const [zipError, setZipError] = useState<string | undefined>();
const [showAll, setShowAll] = useState(false);
const zipId = useId();
const listRef = useRef<HTMLDivElement>(null);
const attempted = useRef(false);
const applyOrigin = useCallback(
(o: LatLng, method: "approximate" | "precise" | "zip", label?: string) => {
const best = nearestCampus(o, openCampuses);
if (!best) return;
setRecommendation({ campus: best.campus, distanceMiles: best.distanceMiles, method, label }, o);
track("campus_recommended", { campus: best.campus.id, method, distance_mi: Math.round(best.distanceMiles) });
},
[setRecommendation],
);
// 1) Non-invasive approximate recommendation on load. Never blocks anything.
useEffect(() => {
if (attempted.current) return;
attempted.current = true;
setState("approx-loading");
locateApprox()
.then((res) => {
if (res.ok && res.approximate && isValidLatLng(res.approximate)) {
applyOrigin({ lat: res.approximate.lat, lng: res.approximate.lng }, "approximate", res.approximate.city);
setState("approx-done");
} else {
setState("approx-failed");
}
})
.catch(() => setState("approx-failed"));
}, [applyOrigin]);
// 2) Optional precise location, only after the visitor asks.
const usePrecise = () => {
track("location_permission_requested", {});
if (typeof navigator === "undefined" || !("geolocation" in navigator)) {
setState("precise-unsupported");
return;
}
setState("precise-loading");
navigator.geolocation.getCurrentPosition(
(pos) => {
const o = { lat: pos.coords.latitude, lng: pos.coords.longitude };
if (!isValidLatLng(o)) return setState("precise-denied");
applyOrigin(o, "precise");
setState("precise-done");
},
(err) => setState(err.code === err.PERMISSION_DENIED ? "precise-denied" : "approx-failed"),
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300_000 },
);
};
// 3) Manual ZIP search, always available.
const onZip = async (e: FormEvent) => {
e.preventDefault();
const z = normalizeZip(zip);
if (!z) {
setZipError("Enter a 5-digit ZIP code.");
return;
}
setZipError(undefined);
setState("zip-loading");
try {
const res = await geocodeZip(z);
if (res.ok && res.location && isValidLatLng(res.location)) {
applyOrigin({ lat: res.location.lat, lng: res.location.lng }, "zip", res.location.city);
setState("zip-done");
} else {
setZipError(res.error ?? "We couldn't find that ZIP. Try another or pick a campus below.");
setState("zip-failed");
}
} catch {
setZipError("Search isn't available right now. Pick a campus below.");
setState("zip-failed");
}
};
const ranked = origin ? rankCampuses(origin, campuses) : null;
const featured = selectedCampus ?? recommendation?.campus ?? null;
const featuredDistance = featured && ranked ? ranked.find((r) => r.campus.id === featured.id)?.distanceMiles : undefined;
const featuredIsRecommendation = !selectedCampus && !!recommendation;
const others = campuses.filter((c) => c.id !== featured?.id);
const farAway = recommendation && recommendation.distanceMiles > FAR_AWAY_MILES;
const statusText = (() => {
switch (state) {
case "approx-loading":
case "precise-loading":
case "zip-loading":
return copy.campus.locating;
case "approx-failed":
return recommendation ? undefined : copy.campus.uncertain;
case "precise-denied":
return copy.campus.denied;
case "precise-unsupported":
return copy.campus.unsupported;
default:
return undefined;
}
})();
const busy = state.endsWith("loading");
return (
<section id="campus" className="on-light bg-bone py-16 text-ink sm:py-24" aria-labelledby="campus-heading">
<Container>
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<SectionHeading id="campus-heading" eyebrow="Step one" title={copy.campus.heading} sub={copy.campus.sub} tone="light" />
<div className="w-full max-w-md lg:w-auto">
<form onSubmit={onZip} noValidate className="flex items-start gap-2">
<div className="flex-1">
<label htmlFor={zipId} className="label">
{copy.campus.zipLabel}
</label>
<input
id={zipId}
inputMode="numeric"
autoComplete="postal-code"
pattern="[0-9]*"
maxLength={10}
placeholder={copy.campus.zipPlaceholder}
className="field"
value={zip}
onChange={(e) => setZip(e.target.value)}
aria-invalid={zipError ? "true" : undefined}
aria-describedby={zipError ? `${zipId}-err` : undefined}
/>
<FieldError id={`${zipId}-err`} message={zipError} />
</div>
<button type="submit" className="btn btn-outline-dark mt-[1.45rem] min-h-[3.25rem] px-4" disabled={busy}>
{state === "zip-loading" ? <Spinner /> : copy.campus.zipButton}
</button>
</form>
<button type="button" onClick={usePrecise} disabled={busy} className="mt-3 inline-flex min-h-11 items-center gap-2 text-sm font-semibold text-teal-deep underline-offset-4 hover:underline disabled:opacity-60">
{state === "precise-loading" ? <Spinner className="size-4" /> : <Icon name="target" className="size-4" />}
{copy.campus.useLocation}
</button>
<p className="mt-2 text-xs leading-relaxed text-slate">{consent.locationBlurb}</p>
</div>
</div>
<div role="status" aria-live="polite" className="mt-8 min-h-6 text-sm font-medium text-slate">
{statusText && (
<span className="inline-flex items-center gap-2">
{busy && <Spinner className="size-4" />}
{statusText}
</span>
)}
{!busy && farAway && <p className="mt-1">{copy.campus.farAway}</p>}
</div>
<div ref={listRef} className="mt-4 space-y-8">
{featured && (
<CampusCard
key={featured.id}
campus={featured}
variant="feature"
distanceMiles={featuredDistance}
badge={
selectedCampus
? copy.campus.chosen
: featuredIsRecommendation
? recommendation?.method === "approximate"
? copy.campus.recommended
: copy.campus.recommendedPrecise
: undefined
}
selected={!!selectedCampus}
onChoose={(id) => selectCampus(id, recommendation?.campus.id === id ? (recommendation.method === "approximate" ? "recommended" : recommendation.method === "zip" ? "zip" : "precise") : "manual")}
onChange={
selectedCampus
? () => {
clearCampus();
setShowAll(true);
}
: undefined
}
/>
)}
<div>
<div className="flex items-center justify-between gap-4">
<h3 className="font-display text-sm font-bold tracking-[0.18em] text-slate uppercase">{copy.campus.all}</h3>
{featured && others.length > 3 && (
<button type="button" className="text-sm font-semibold text-teal-deep underline-offset-4 hover:underline lg:hidden" onClick={() => setShowAll((v) => !v)} aria-expanded={showAll}>
{showAll ? "Show fewer" : `Show all ${others.length}`}
</button>
)}
</div>
<div className="mt-4 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{(featured && !showAll ? others.slice(0, 3) : others).map((c, i) => (
<div key={c.id} className={featured && !showAll && i >= 3 ? "hidden lg:block" : ""}>
<CampusCard
campus={c}
distanceMiles={ranked?.find((r) => r.campus.id === c.id)?.distanceMiles}
selected={selectedCampus?.id === c.id}
onChoose={(id) => {
selectCampus(id, "manual");
listRef.current?.scrollIntoView?.({ behavior: "smooth", block: "start" });
}}
/>
</div>
))}
</div>
</div>
</div>
</Container>
</section>
);
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { formatAddress, formatTimes, type Campus } from "@/config/campuses";
import { copy } from "@/content/copy";
import type { SubmitResponse } from "@/lib/api";
import { track } from "@/lib/analytics";
import { TextUsLink } from "@/components/TextUsButton";
import { Icon } from "@/components/ui";
interface Props {
campus: Campus;
firstName: string;
visitDate?: string;
serviceTime?: string;
emailConsent: boolean;
smsConsent: boolean;
result: SubmitResponse;
onReset: () => void;
}
function prettyDate(iso?: string): string | null {
if (!iso) return null;
const [y, m, d] = iso.split("-").map(Number);
const dt = new Date(y, m - 1, d);
return dt.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
}
export function Confirmation({ campus, firstName, visitDate, serviceTime, emailConsent, smsConsent, result, onReset }: Props) {
const date = prettyDate(visitDate);
const smsMessage = `Hi! I'm planning to visit Waters Church ${campus.name} and have a question.`;
const smsStatus = result.sms?.status;
return (
<div className="card on-light animate-rise overflow-hidden" role="status" aria-live="polite">
<div className="bg-teal px-6 py-8 text-ink sm:px-10">
<p className="eyebrow text-teal-ink">Plan confirmed</p>
<h3 className="display-lg mt-2">
{copy.confirm.heading}
<br />
<span className="text-paper">{firstName ? `See you soon, ${firstName}.` : copy.confirm.sub}</span>
</h3>
</div>
<div className="grid gap-8 p-6 sm:p-10 md:grid-cols-[1.2fr_1fr]">
<div>
<p className="eyebrow text-teal-deep">Your campus</p>
<p className="display-md mt-1">{campus.name}</p>
<address className="mt-3 whitespace-pre-line text-slate not-italic">{formatAddress(campus, true)}</address>
<ul className="mt-4 space-y-1.5">
{campus.serviceTimes.map((st) => (
<li key={st.day} className="flex items-start gap-2">
<Icon name="clock" className="mt-0.5 size-5 text-teal-deep" />
<span>
<span className="font-semibold">{st.day}</span> <span className="text-slate">· {formatTimes(st.times)}</span>
</span>
</li>
))}
</ul>
{(date || serviceTime) && (
<p className="mt-4 rounded-brand bg-bone px-4 py-3 text-sm">
<span className="font-semibold">Your plan:</span> {[date, serviceTime].filter(Boolean).join(" · ")}
</p>
)}
<div className="mt-6 flex flex-col gap-2 sm:flex-row">
<a href={campus.directionsUrl} target="_blank" rel="noopener" className="btn btn-outline-dark" onClick={() => track("directions_clicked", { campus: campus.id, location: "confirmation" })}>
<Icon name="pin" /> {copy.campus.directions}
</a>
<TextUsLink number={campus.smsNumber} message={smsMessage} location="confirmation" className="btn btn-primary">
<Icon name="sms" /> {copy.confirm.textUs}
</TextUsLink>
</div>
</div>
<div className="space-y-4 border-t border-ink/10 pt-6 md:border-t-0 md:border-l md:pt-0 md:pl-8">
<p className="eyebrow text-teal-deep">What happens next</p>
<ul className="space-y-3 text-sm leading-relaxed">
{emailConsent && (
<li className="flex gap-2.5">
<Icon name="check" className="mt-0.5 size-5 shrink-0 text-success" />
<span>{copy.confirm.emailNote}</span>
</li>
)}
{smsConsent && (
<li className="flex gap-2.5">
<Icon name="check" className="mt-0.5 size-5 shrink-0 text-success" />
<span>{smsStatus === "sent" || smsStatus === "mock" ? copy.confirm.smsNote : copy.confirm.smsPending}</span>
</li>
)}
<li className="flex gap-2.5">
<Icon name="check" className="mt-0.5 size-5 shrink-0 text-success" />
<span>When you arrive, look for the welcome team. Tell them it&apos;s your first visit and they&apos;ll take it from there.</span>
</li>
</ul>
<button type="button" onClick={onReset} className="text-sm font-semibold text-teal-deep underline-offset-4 hover:underline">
{copy.confirm.again}
</button>
</div>
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { logos } from "@/config/images";
import { site } from "@/config/site";
import { copy } from "@/content/copy";
import { openCampuses } from "@/config/campuses";
export function Footer() {
return (
<footer className="border-t border-paper/10 bg-ink text-mist">
<div className="mx-auto grid max-w-6xl gap-10 px-5 py-14 sm:px-8 md:grid-cols-[1.2fr_1fr_1fr]">
<div>
<img src={logos.wordmarkWhite} alt={site.name} width={220} height={18} className="h-[16px] w-auto" />
<p className="mt-4 max-w-sm text-sm leading-relaxed">{site.tagline}</p>
<p className="mt-6 text-sm">
{copy.footer.online}{" "}
<a href={site.watchUrl} className="font-semibold text-paper underline-offset-4 hover:underline">
Watch live
</a>
</p>
</div>
<div>
<h2 className="eyebrow mb-4 text-paper/80">Locations</h2>
<ul className="space-y-2 text-sm">
{openCampuses.map((c) => (
<li key={c.id}>
<a href={c.campusUrl} className="hover:text-paper">
{c.label}
</a>
</li>
))}
</ul>
</div>
<div>
<h2 className="eyebrow mb-4 text-paper/80">Contact</h2>
<address className="space-y-2 text-sm not-italic">
<p>
{site.address.line1}
<br />
{site.address.city}, {site.address.state} {site.address.postalCode}
</p>
<p>
<a href={`tel:${site.phone}`} className="hover:text-paper">
{site.phoneDisplay}
</a>
</p>
<p>
<a href={`mailto:${site.email}`} className="hover:text-paper">
{site.email}
</a>
</p>
<p className="text-xs">Office: {site.officeHours}</p>
</address>
</div>
</div>
<div className="border-t border-paper/10">
<div className="mx-auto flex max-w-6xl flex-col gap-3 px-5 py-6 text-xs sm:flex-row sm:items-center sm:justify-between sm:px-8">
<p>© {new Date().getFullYear()} {site.name}. All rights reserved.</p>
<ul className="flex flex-wrap gap-x-5 gap-y-2">
<li>
<a href={site.mainSiteUrl} className="hover:text-paper">
waterschurch.org
</a>
</li>
<li>
<a href={site.privacyUrl} className="hover:text-paper">
Privacy Policy
</a>
</li>
{site.termsUrl && (
<li>
<a href={site.termsUrl} className="hover:text-paper">
Terms
</a>
</li>
)}
</ul>
</div>
</div>
</footer>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { copy } from "@/content/copy";
import { logos } from "@/config/images";
import { site } from "@/config/site";
import { TextUsLink } from "@/components/TextUsButton";
import { Icon } from "@/components/ui";
export function Header() {
return (
<header className="sticky top-0 z-30 border-b border-paper/10 bg-ink/85 backdrop-blur supports-[backdrop-filter]:bg-ink/70">
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between gap-4 px-5 sm:px-8">
<a href="#top" className="flex items-center gap-3" aria-label={`${site.name} home`}>
<img src={logos.markWhite} alt="" width={36} height={36} className="size-9" />
<img src={logos.wordmarkWhite} alt={site.name} width={160} height={13} className="hidden h-[13px] w-auto sm:block" />
</a>
<nav aria-label="Primary" className="flex items-center gap-2 sm:gap-3">
<a href="#campus" className="btn btn-ghost min-h-11 px-3 text-sm text-paper/90">
<Icon name="pin" className="size-4" />
<span className="hidden sm:inline">{copy.header.findCampus}</span>
<span className="sm:hidden">Campus</span>
</a>
<TextUsLink location="header" className="btn btn-outline min-h-11 px-4 text-sm">
<Icon name="sms" className="size-4" /> {copy.header.textUs}
</TextUsLink>
</nav>
</div>
</header>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { copy } from "@/content/copy";
import { Picture } from "@/components/Picture";
import { TrackedLink } from "@/components/TrackedLink";
import { TextUsLink } from "@/components/TextUsButton";
import { Icon } from "@/components/ui";
export function Hero() {
return (
<section id="top" className="relative isolate overflow-hidden bg-ink" aria-labelledby="hero-heading">
<Picture
name="hero-worship"
alt="A packed Waters Church auditorium during worship, with a live band and bright stage lights"
sizes="100vw"
priority
fill
className="absolute inset-0 -z-10"
imgClassName="opacity-70 object-[50%_35%]"
/>
<div className="absolute inset-0 -z-10 bg-gradient-to-t from-ink via-ink/70 to-ink/20" aria-hidden="true" />
<div className="absolute inset-x-0 bottom-0 -z-10 h-px bg-teal/60" aria-hidden="true" />
<div className="mx-auto flex min-h-[calc(100svh-4rem)] max-w-6xl flex-col justify-end px-5 pt-24 pb-10 sm:px-8 sm:pb-16 lg:min-h-[min(88svh,52rem)]">
<p className="eyebrow animate-fade mb-4">{copy.hero.eyebrow}</p>
<h1 id="hero-heading" className="display-xl animate-rise max-w-5xl">
{copy.hero.headline[0]}
<br />
<span className="text-teal">{copy.hero.headline[1]}</span>
</h1>
<p className="animate-rise mt-6 max-w-xl text-lg leading-relaxed text-paper/85 [animation-delay:80ms] sm:text-xl">
{copy.hero.sub}
</p>
<div className="animate-rise mt-8 flex flex-col gap-3 [animation-delay:140ms] sm:flex-row sm:items-center">
<TrackedLink href="#plan" event="plan_visit_started" props={{ location: "hero" }} className="btn btn-primary w-full sm:w-auto">
{copy.hero.primaryCta} <Icon name="arrow" />
</TrackedLink>
<a href="#campus" className="btn btn-outline w-full sm:w-auto">
<Icon name="pin" /> {copy.hero.secondaryCta}
</a>
<TextUsLink location="hero" className="btn btn-ghost w-full text-paper/90 sm:w-auto">
<Icon name="sms" /> {copy.hero.textCta}
</TextUsLink>
</div>
<p className="animate-fade mt-8 inline-flex items-center gap-2 text-sm font-medium text-mist [animation-delay:240ms]">
<span className="inline-block size-2 rounded-full bg-teal" aria-hidden="true" />
{copy.hero.trust}
</p>
</div>
</section>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { openCampuses } from "@/config/campuses";
import { site } from "@/config/site";
/**
* Organization + Church schema using only verified information.
* Service times are not opening hours, so they are intentionally omitted.
*/
export function JsonLd() {
const org = {
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${site.mainSiteUrl}/#organization`,
name: site.name,
url: site.mainSiteUrl,
logo: `${site.url}/images/waters-mark-teal-512.png`,
email: site.email,
telephone: site.phone,
address: {
"@type": "PostalAddress",
streetAddress: site.address.line1,
addressLocality: site.address.city,
addressRegion: site.address.state,
postalCode: site.address.postalCode,
addressCountry: site.address.country,
},
sameAs: Object.values(site.social),
};
const page = {
"@context": "https://schema.org",
"@type": "WebPage",
"@id": `${site.url}/#webpage`,
url: site.url,
name: "Plan Your Visit · Waters Church",
isPartOf: { "@id": `${site.mainSiteUrl}/#organization` },
about: { "@id": `${site.mainSiteUrl}/#organization` },
primaryImageOfPage: `${site.url}/images/og-image.jpg`,
};
const churches = openCampuses.map((c) => ({
"@context": "https://schema.org",
"@type": "Church",
"@id": `${c.campusUrl}#place`,
name: `${site.name} ${c.name}`,
url: c.campusUrl,
telephone: c.phone,
parentOrganization: { "@id": `${site.mainSiteUrl}/#organization` },
address: {
"@type": "PostalAddress",
streetAddress: c.address.line1,
addressLocality: c.address.city,
addressRegion: c.address.state,
postalCode: c.address.postalCode,
addressCountry: "US",
},
geo: { "@type": "GeoCoordinates", latitude: c.coordinates.lat, longitude: c.coordinates.lng },
hasMap: c.directionsUrl,
}));
return (
<>
{[org, page, ...churches].map((obj, i) => (
<script key={i} type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(obj) }} />
))}
</>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { site } from "@/config/site";
import { copy } from "@/content/copy";
import { kidsAgeGroups, kidsPoints } from "@/content/kids";
import { Picture } from "@/components/Picture";
import { Container, Icon, SectionHeading } from "@/components/ui";
export function KidsSection() {
return (
<section id="kids" className="on-light bg-bone py-16 text-ink sm:py-24" aria-labelledby="kids-heading">
<Container>
<SectionHeading id="kids-heading" eyebrow="Families" title={copy.kids.heading} sub={copy.kids.sub} tone="light" />
<div className="mt-10 grid gap-8 lg:grid-cols-[1fr_1fr]">
<div className="card overflow-hidden">
<div className="relative aspect-[16/9]">
<Picture
name="community-1"
alt="Waters Church volunteers greeting guests, people worshipping, and a Waters Church in Action volunteer shirt"
sizes="(min-width: 1024px) 50vw, 100vw"
fill
className="absolute inset-0"
/>
</div>
<div className="p-6 sm:p-8">
<h3 className="font-display text-sm font-bold tracking-[0.18em] text-slate uppercase">Age groups at every service</h3>
<ul className="mt-4 grid gap-3 sm:grid-cols-2">
{kidsAgeGroups.map((g) => (
<li key={g.id} className="rounded-brand border border-ink/10 p-4">
<p className="font-display text-2xl font-black tracking-tight text-teal-deep">{g.ages}</p>
<p className="mt-1 font-semibold">{g.label}</p>
<p className="text-sm text-slate">{g.rooms}</p>
</li>
))}
</ul>
<a href={site.kidsUrl} className="mt-5 inline-flex items-center gap-1.5 text-sm font-semibold text-teal-deep underline-offset-4 hover:underline">
More about Waters Kids <Icon name="arrow" className="size-4" />
</a>
</div>
</div>
<ul className="grid content-start gap-4 sm:grid-cols-2">
{kidsPoints.map((p) => (
<li key={p.id} className="rounded-brand bg-paper p-5 shadow-lift">
<h3 className="font-display text-base font-extrabold tracking-tight uppercase">{p.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-slate">{p.body}</p>
{p.placeholder && process.env.NODE_ENV !== "production" && (
<p className="mt-2 rounded bg-yellow-100 px-2 py-1 text-[11px] font-semibold text-yellow-900">Placeholder: confirm with Waters Church</p>
)}
</li>
))}
</ul>
</div>
</Container>
</section>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { images, type ImageKey } from "@/config/images";
interface PictureProps {
name: ImageKey;
alt: string;
sizes: string;
className?: string;
imgClassName?: string;
priority?: boolean;
/** Fill parent (absolute, object-cover). */
fill?: boolean;
}
/**
* Responsive <picture> backed by the build-time manifest (AVIF → WebP → JPEG).
* Width/height are always set so there is no layout shift.
*/
export function Picture({ name, alt, sizes, className, imgClassName, priority, fill }: PictureProps) {
const entry = images[name];
const srcset = (ext: string) => entry.widths.map((w) => `/images/${name}-${w}.${ext} ${w}w`).join(", ");
const largest = entry.widths[entry.widths.length - 1];
const fillClass = fill ? "absolute inset-0 h-full w-full object-cover" : "h-auto w-full";
return (
<picture className={className}>
<source type="image/avif" srcSet={srcset("avif")} sizes={sizes} />
<source type="image/webp" srcSet={srcset("webp")} sizes={sizes} />
<img
src={`/images/${name}-${largest}.jpg`}
srcSet={srcset("jpg")}
sizes={sizes}
width={entry.width}
height={entry.height}
alt={alt}
loading={priority ? "eager" : "lazy"}
decoding={priority ? "sync" : "async"}
fetchPriority={priority ? "high" : undefined}
className={`${fillClass} ${imgClassName ?? ""}`}
style={entry.blur && !priority ? { backgroundImage: `url(${entry.blur})`, backgroundSize: "cover" } : undefined}
/>
</picture>
);
}
+530
View File
@@ -0,0 +1,530 @@
"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type FormEvent } from "react";
import { openCampuses, serviceOptions, type Campus } from "@/config/campuses";
import { site } from "@/config/site";
import { consent } from "@/content/consent";
import { copy } from "@/content/copy";
import { childAgeRangeOptions } from "@/content/kids";
import { submitPlanVisit, type SubmitResponse } from "@/lib/api";
import { track } from "@/lib/analytics";
import { attributionForApi } from "@/lib/attribution";
import { issuesToMap, planVisitSchema, stepFields, todayIsoDate, type PlanVisitInput } from "@/lib/schema";
import { Confirmation } from "@/components/Confirmation";
import { useVisit } from "@/components/VisitProvider";
import { Container, FieldError, Icon, SectionHeading, Spinner } from "@/components/ui";
type Status = "idle" | "submitting" | "success" | "error";
function newId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
// RFC4122-ish fallback for very old browsers
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
});
}
type FormValues = Omit<PlanVisitInput, "campusId">;
function initialValues(): FormValues {
return {
submissionId: newId(),
firstName: "",
lastName: "",
email: "",
phone: "",
visitDate: "",
serviceTime: "",
adults: undefined,
children: undefined,
childAgeRanges: [],
questions: "",
emailConsent: false,
smsConsent: false,
consentVersion: consent.version,
website: "",
startedAt: Date.now(),
attribution: {},
};
}
declare global {
interface Window {
turnstile?: { render: (el: HTMLElement, opts: Record<string, unknown>) => string; reset: (id?: string) => void };
}
}
export function PlanVisitForm() {
const { selectedCampus, selectCampus, getAttribution } = useVisit();
const [values, setValues] = useState<FormValues>(() => initialValues());
const [errors, setErrors] = useState<Record<string, string>>({});
const [step, setStep] = useState(0);
const [status, setStatus] = useState<Status>("idle");
const [result, setResult] = useState<SubmitResponse | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [turnstileToken, setTurnstileToken] = useState<string>("");
const headingRef = useRef<HTMLHeadingElement>(null);
const formRef = useRef<HTMLFormElement>(null);
const turnstileRef = useRef<HTMLDivElement>(null);
const started = useRef(false);
const id = useId();
// The selected campus lives in VisitProvider (shared with the campus finder), so the
// two always agree without any syncing. A service time only counts if it belongs to it.
const campus: Campus | undefined = selectedCampus ?? undefined;
const services = useMemo(() => (campus ? serviceOptions(campus) : []), [campus]);
const serviceTime = services.some((o) => o.value === values.serviceTime) ? values.serviceTime : "";
const withCampus = useCallback(
(v: FormValues): PlanVisitInput => ({ ...v, campusId: (campus?.id ?? "") as PlanVisitInput["campusId"], serviceTime: services.some((o) => o.value === v.serviceTime) ? v.serviceTime : "" }),
[campus, services],
);
const set = useCallback(<K extends keyof FormValues>(key: K, val: FormValues[K]) => {
setValues((v) => ({ ...v, [key]: val }));
setErrors((e) => (e[key as string] ? { ...e, [key as string]: "" } : e));
if (!started.current) {
started.current = true;
track("plan_visit_started", { location: "form" });
}
}, []);
/** Move focus to the step heading for screen-reader users, unless the visitor already focused a field. */
const focusHeading = () =>
requestAnimationFrame(() => {
const active = document.activeElement;
if (!active || active === document.body || !formRef.current?.contains(active)) headingRef.current?.focus();
});
const focusFirstError = (errs: Record<string, string>) => {
const first = Object.keys(errs).find((k) => errs[k]);
if (!first) return;
requestAnimationFrame(() => {
const el = formRef.current?.querySelector<HTMLElement>(`[name="${first}"]`);
el?.focus();
});
};
/** Validate only the fields belonging to `s`. */
const validateStep = (s: number): boolean => {
const parsed = planVisitSchema.safeParse(withCampus(values));
if (parsed.success) return true;
const all = issuesToMap(parsed.error.issues);
const relevant: Record<string, string> = {};
for (const f of stepFields[s]) if (all[f as string]) relevant[f as string] = all[f as string];
if (Object.keys(relevant).length) {
setErrors((e) => ({ ...e, ...relevant }));
focusFirstError(relevant);
return false;
}
return true;
};
const next = () => {
if (!validateStep(step)) return;
track("plan_visit_step_completed", { step: step + 1, step_name: copy.form.steps[step], campus: campus?.id });
setStep((s) => Math.min(s + 1, 2));
focusHeading();
};
const back = () => {
setStep((s) => Math.max(s - 1, 0));
focusHeading();
};
// Cloudflare Turnstile (optional). Rendered only on the final step when a site key is configured.
useEffect(() => {
if (!site.turnstileSiteKey || step !== 2 || !turnstileRef.current) return;
const render = () => {
if (!window.turnstile || !turnstileRef.current || turnstileRef.current.childElementCount) return;
window.turnstile.render(turnstileRef.current, {
sitekey: site.turnstileSiteKey,
theme: "light",
callback: (t: string) => setTurnstileToken(t),
"expired-callback": () => setTurnstileToken(""),
});
};
if (window.turnstile) render();
else {
const s = document.createElement("script");
s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
s.async = true;
s.onload = render;
document.head.appendChild(s);
}
}, [step]);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
if (status === "submitting") return;
setFormError(null);
const payload: PlanVisitInput = {
...withCampus(values),
attribution: attributionForApi(getAttribution()),
turnstileToken: turnstileToken || undefined,
};
const parsed = planVisitSchema.safeParse(payload);
if (!parsed.success) {
const errs = issuesToMap(parsed.error.issues);
setErrors(errs);
// jump to the earliest step with an error
const stepWithError = [0, 1, 2].find((s) => stepFields[s].some((f) => errs[f as string]));
if (stepWithError !== undefined && stepWithError !== step) setStep(stepWithError);
focusFirstError(errs);
return;
}
if (site.turnstileSiteKey && !turnstileToken) {
setFormError("Please complete the quick security check above.");
return;
}
setStatus("submitting");
track("plan_visit_submitted", { campus: parsed.data.campusId, sms_consent: parsed.data.smsConsent, email_consent: parsed.data.emailConsent });
try {
const res = await submitPlanVisit(parsed.data);
if (res.ok) {
setResult(res);
setStatus("success");
// Fire conversion only after the server confirmed persistence.
track("plan_visit_success", { campus: parsed.data.campusId, duplicate: !!res.duplicate });
requestAnimationFrame(() => document.getElementById("plan")?.scrollIntoView?.({ behavior: "smooth", block: "start" }));
} else if (res.fieldErrors && Object.keys(res.fieldErrors).length) {
setErrors(res.fieldErrors);
const stepWithError = [0, 1, 2].find((s) => stepFields[s].some((f) => res.fieldErrors![f as string]));
if (stepWithError !== undefined) setStep(stepWithError);
setStatus("idle");
focusFirstError(res.fieldErrors);
} else {
setStatus("error");
setFormError(res.error ?? "Something went wrong on our end. Please try again in a moment.");
track("plan_visit_error", { reason: res.error ?? "server" });
window.turnstile?.reset();
setTurnstileToken("");
}
} catch (err) {
setStatus("error");
const aborted = err instanceof DOMException && err.name === "AbortError";
setFormError(aborted ? "That took too long. Check your connection and try again. Your answers are still here." : "We couldn't send that just now. Please try again, or text us and we'll help.");
track("plan_visit_error", { reason: aborted ? "timeout" : "network" });
}
};
const reset = () => {
setValues(initialValues());
setErrors({});
setStep(0);
setStatus("idle");
setResult(null);
setFormError(null);
setTurnstileToken("");
started.current = false;
focusHeading();
};
const f = (name: string) => `${id}-${name}`;
const err = (name: string) => errors[name] || undefined;
const describedBy = (name: string, extra?: string) => [err(name) ? f(`${name}-err`) : null, extra].filter(Boolean).join(" ") || undefined;
return (
<section id="plan" className="relative isolate scroll-mt-16 bg-ink py-16 text-paper sm:py-24" aria-labelledby="plan-heading">
<div className="stripe absolute inset-x-0 top-0 h-1.5 opacity-60" aria-hidden="true" />
<Container>
<div className="grid gap-10 lg:grid-cols-[1fr_1.5fr] lg:gap-16">
<div className="lg:sticky lg:top-24 lg:self-start">
<SectionHeading id="plan-heading" eyebrow="Step three" title={copy.form.heading} sub={copy.form.sub} />
<ol className="mt-8 hidden gap-3 lg:flex lg:flex-col" aria-label="Form progress">
{copy.form.steps.map((label, i) => {
const state = status === "success" ? "done" : i < step ? "done" : i === step ? "current" : "todo";
return (
<li key={label} className="flex items-center gap-3 text-sm" aria-current={state === "current" ? "step" : undefined}>
<span
className={`flex size-8 items-center justify-center rounded-full font-display text-xs font-black ${
state === "done" ? "bg-teal text-ink" : state === "current" ? "border-2 border-teal text-teal" : "border-2 border-paper/20 text-mist"
}`}
>
{state === "done" ? <Icon name="check" className="size-4" /> : i + 1}
</span>
<span className={state === "todo" ? "text-mist" : "font-semibold"}>{label}</span>
</li>
);
})}
</ol>
<p className="mt-8 max-w-sm text-sm text-mist">{consent.privacyBlurb}</p>
</div>
{status === "success" && result && campus ? (
<Confirmation
campus={campus}
firstName={values.firstName}
visitDate={values.visitDate || undefined}
serviceTime={serviceTime || undefined}
emailConsent={values.emailConsent}
smsConsent={values.smsConsent}
result={result}
onReset={reset}
/>
) : (
<form ref={formRef} onSubmit={onSubmit} noValidate className="card on-light p-6 sm:p-10" aria-describedby={formError ? f("form-err") : undefined}>
{/* Mobile progress */}
<p className="mb-6 flex items-center gap-2 font-display text-xs font-bold tracking-[0.18em] text-slate uppercase lg:hidden">
<span className="text-teal-deep">Step {step + 1} of 3</span> · {copy.form.steps[step]}
</p>
<h3 ref={headingRef} tabIndex={-1} className="display-md outline-none">
{copy.form.steps[step]}
</h3>
{/* Honeypot: hidden from humans, tempting for bots */}
<div className="absolute -left-[9999px] h-px w-px overflow-hidden" aria-hidden="true">
<label htmlFor={f("website")}>Website</label>
<input id={f("website")} name="website" type="text" tabIndex={-1} autoComplete="off" value={values.website ?? ""} onChange={(e) => set("website", e.target.value as "")} />
</div>
{step === 0 && (
<fieldset className="mt-6">
<legend className="label">Which location will you visit?</legend>
<div className="grid gap-2 sm:grid-cols-2">
{openCampuses.map((c) => {
const checked = campus?.id === c.id;
return (
<label
key={c.id}
className={`flex min-h-16 cursor-pointer items-center gap-3 rounded-brand border-2 px-4 py-3 transition-colors ${
checked ? "border-teal-deep bg-teal/10" : "border-ink/15 hover:border-ink/40"
}`}
>
<input
type="radio"
name="campusId"
value={c.id}
checked={checked}
onChange={() => {
selectCampus(c.id, "manual");
set("serviceTime", "");
setErrors((e) => ({ ...e, campusId: "" }));
}}
className="check rounded-full"
aria-describedby={describedBy("campusId")}
/>
<span>
<span className="block font-display font-extrabold tracking-tight uppercase">{c.label}</span>
<span className="block text-xs text-slate">
{c.address.city}, {c.address.state}
</span>
</span>
</label>
);
})}
</div>
<FieldError id={f("campusId-err")} message={err("campusId")} />
</fieldset>
)}
{step === 1 && (
<div className="mt-6 grid gap-5 sm:grid-cols-2">
<div>
<label htmlFor={f("firstName")} className="label">
First name <span className="text-danger">*</span>
</label>
<input id={f("firstName")} name="firstName" className="field" autoComplete="given-name" required value={values.firstName} onChange={(e) => set("firstName", e.target.value)} aria-invalid={!!err("firstName")} aria-describedby={describedBy("firstName")} />
<FieldError id={f("firstName-err")} message={err("firstName")} />
</div>
<div>
<label htmlFor={f("lastName")} className="label">
Last name <span className="font-normal text-slate/80 normal-case tracking-normal">(optional)</span>
</label>
<input id={f("lastName")} name="lastName" className="field" autoComplete="family-name" value={values.lastName ?? ""} onChange={(e) => set("lastName", e.target.value)} aria-invalid={!!err("lastName")} aria-describedby={describedBy("lastName")} />
<FieldError id={f("lastName-err")} message={err("lastName")} />
</div>
<div className="sm:col-span-2">
<label htmlFor={f("email")} className="label">
Email <span className="text-danger">*</span>
</label>
<input id={f("email")} name="email" type="email" inputMode="email" className="field" autoComplete="email" required value={values.email} onChange={(e) => set("email", e.target.value)} aria-invalid={!!err("email")} aria-describedby={describedBy("email")} />
<FieldError id={f("email-err")} message={err("email")} />
</div>
<div className="sm:col-span-2">
<label htmlFor={f("phone")} className="label">
Mobile phone{" "}
{values.smsConsent ? <span className="text-danger">*</span> : <span className="font-normal text-slate/80 normal-case tracking-normal">(optional)</span>}
</label>
<input id={f("phone")} name="phone" type="tel" inputMode="tel" className="field" autoComplete="tel" placeholder="(555) 555-5555" value={values.phone ?? ""} onChange={(e) => set("phone", e.target.value)} aria-invalid={!!err("phone")} aria-describedby={describedBy("phone", f("phone-hint"))} />
<p id={f("phone-hint")} className="mt-1.5 text-xs text-slate">
Only needed if you&apos;d like a confirmation text.
</p>
<FieldError id={f("phone-err")} message={err("phone")} />
</div>
<fieldset className="sm:col-span-2">
<legend className="label">Stay in touch</legend>
<div className="space-y-3">
<label className="flex cursor-pointer gap-3 rounded-brand border-2 border-ink/10 p-4 hover:border-ink/30">
<input type="checkbox" name="emailConsent" className="check" checked={values.emailConsent} onChange={(e) => set("emailConsent", e.target.checked)} />
<span className="text-sm">
<span className="block font-semibold">{consent.email.label}</span>
<span className="block text-slate">{consent.email.detail}</span>
</span>
</label>
<label className="flex cursor-pointer gap-3 rounded-brand border-2 border-ink/10 p-4 hover:border-ink/30">
<input type="checkbox" name="smsConsent" className="check" checked={values.smsConsent} onChange={(e) => set("smsConsent", e.target.checked)} />
<span className="text-sm">
<span className="block font-semibold">{consent.sms.label}</span>
<span className="block text-slate">{consent.sms.detail}</span>
</span>
</label>
</div>
<p className="mt-3 text-xs text-slate">
See our{" "}
<a href={site.privacyUrl} className="underline" target="_blank" rel="noopener">
Privacy Policy
</a>
{site.termsUrl && (
<>
{" "}
and{" "}
<a href={site.termsUrl} className="underline" target="_blank" rel="noopener">
Terms
</a>
</>
)}
.
</p>
</fieldset>
</div>
)}
{step === 2 && (
<div className="mt-6 grid gap-5 sm:grid-cols-2">
<div>
<label htmlFor={f("visitDate")} className="label">
When are you thinking? <span className="font-normal text-slate/80 normal-case tracking-normal">(optional)</span>
</label>
<input id={f("visitDate")} name="visitDate" type="date" className="field" min={todayIsoDate()} value={values.visitDate ?? ""} onChange={(e) => set("visitDate", e.target.value)} aria-invalid={!!err("visitDate")} aria-describedby={describedBy("visitDate")} />
<FieldError id={f("visitDate-err")} message={err("visitDate")} />
</div>
<div>
<label htmlFor={f("serviceTime")} className="label">
Which service? <span className="font-normal text-slate/80 normal-case tracking-normal">(optional)</span>
</label>
<select id={f("serviceTime")} name="serviceTime" className="field" value={serviceTime ?? ""} onChange={(e) => set("serviceTime", e.target.value)}>
<option value="">Not sure yet</option>
{services.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor={f("adults")} className="label">
Adults <span className="font-normal text-slate/80 normal-case tracking-normal">(optional)</span>
</label>
<select id={f("adults")} name="adults" className="field" value={values.adults ?? ""} onChange={(e) => set("adults", e.target.value ? Number(e.target.value) : undefined)}>
<option value="">Select</option>
{[1, 2, 3, 4, 5, 6].map((n) => (
<option key={n} value={n}>
{n}
{n === 6 ? "+" : ""}
</option>
))}
</select>
</div>
<div>
<label htmlFor={f("children")} className="label">
Kids <span className="font-normal text-slate/80 normal-case tracking-normal">(optional)</span>
</label>
<select
id={f("children")}
name="children"
className="field"
value={values.children ?? ""}
onChange={(e) => {
const n = e.target.value ? Number(e.target.value) : undefined;
set("children", n);
if (!n) set("childAgeRanges", []);
}}
>
<option value="">Select</option>
{[0, 1, 2, 3, 4, 5].map((n) => (
<option key={n} value={n}>
{n}
{n === 5 ? "+" : ""}
</option>
))}
</select>
</div>
{!!values.children && (
<fieldset className="sm:col-span-2">
<legend className="label">Their ages</legend>
<div className="flex flex-wrap gap-2">
{childAgeRangeOptions.map((o) => {
const on = values.childAgeRanges?.includes(o.value) ?? false;
return (
<label key={o.value} className={`inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-brand border-2 px-3 text-sm font-semibold ${on ? "border-teal-deep bg-teal/10" : "border-ink/15"}`}>
<input
type="checkbox"
name="childAgeRanges"
className="check size-5"
checked={on}
onChange={(e) => {
const cur = new Set(values.childAgeRanges ?? []);
if (e.target.checked) cur.add(o.value);
else cur.delete(o.value);
set("childAgeRanges", [...cur]);
}}
/>
{o.label}
</label>
);
})}
</div>
</fieldset>
)}
<div className="sm:col-span-2">
<label htmlFor={f("questions")} className="label">
What questions can we answer? <span className="font-normal text-slate/80 normal-case tracking-normal">(optional)</span>
</label>
<textarea id={f("questions")} name="questions" rows={3} maxLength={1000} className="field resize-y" value={values.questions ?? ""} onChange={(e) => set("questions", e.target.value)} aria-describedby={describedBy("questions")} />
<FieldError id={f("questions-err")} message={err("questions")} />
</div>
{site.turnstileSiteKey && <div ref={turnstileRef} className="sm:col-span-2" />}
</div>
)}
{formError && (
<p id={f("form-err")} role="alert" className="mt-6 rounded-brand border-2 border-danger/40 bg-danger/5 p-4 text-sm font-medium text-danger">
{formError}
</p>
)}
<div className="mt-8 flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between">
{step > 0 ? (
<button type="button" onClick={back} className="btn btn-outline-dark min-h-12 sm:min-h-14">
{copy.form.back}
</button>
) : (
<span />
)}
{step < 2 ? (
<button type="button" onClick={next} className="btn btn-primary w-full sm:w-auto">
{copy.form.next} <Icon name="arrow" />
</button>
) : (
<button type="submit" className="btn btn-primary w-full sm:w-auto" disabled={status === "submitting"} aria-busy={status === "submitting"}>
{status === "submitting" ? (
<>
<Spinner /> {copy.form.submitting}
</>
) : (
<>
{copy.form.submit} <Icon name="arrow" />
</>
)}
</button>
)}
</div>
</form>
)}
</div>
</Container>
</section>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { copy } from "@/content/copy";
import { showTestimonialPlaceholderInDev, testimonials } from "@/content/testimonials";
import { Container, SectionHeading } from "@/components/ui";
/**
* Renders real testimonials only. With none configured, the section is omitted
* in production and shows an obvious placeholder in development builds.
*/
export function SocialProof() {
const isProd = process.env.NODE_ENV === "production";
if (testimonials.length === 0 && (isProd || !showTestimonialPlaceholderInDev)) return null;
return (
<section id="stories" className="bg-ink py-16 text-paper sm:py-24" aria-labelledby="proof-heading">
<Container>
<SectionHeading id="proof-heading" eyebrow="Real people" title={copy.proof.heading} />
<ul className="mt-10 grid gap-5 md:grid-cols-3">
{testimonials.length === 0 ? (
<li className="rounded-brand border-2 border-dashed border-yellow-400/60 p-6 text-sm text-yellow-200 md:col-span-3">
<strong>Content placeholder (dev only):</strong> add verified testimonials to <code>src/content/testimonials.ts</code>. This block never renders in production builds.
</li>
) : (
testimonials.map((t) => (
<li key={t.name + t.quote.slice(0, 12)} className="flex flex-col rounded-brand bg-ink-2 p-6">
<blockquote className="flex-1 text-lg leading-relaxed">“{t.quote}”</blockquote>
<footer className="mt-5 text-sm text-mist">
<span className="font-semibold text-paper">{t.name}</span>
{t.context && <span> · {t.context}</span>}
</footer>
</li>
))
)}
</ul>
</Container>
</section>
);
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { useEffect, useState } from "react";
import { site } from "@/config/site";
import { track } from "@/lib/analytics";
import { smsLink } from "@/lib/phone";
import { useVisit } from "@/components/VisitProvider";
import { Icon } from "@/components/ui";
interface TextUsLinkProps {
className?: string;
children?: React.ReactNode;
location: string;
/** Override the number (e.g. a specific campus card). */
number?: string | null;
message?: string;
}
/** Any "Text Us" control. Uses the selected campus number, else the central number. */
export function TextUsLink({ className = "", children, location, number, message }: TextUsLinkProps) {
const { smsNumber, selectedCampus } = useVisit();
const target = number ?? smsNumber;
const body = message ?? site.textUsMessage;
return (
<a
href={smsLink(target, body)}
className={className}
onClick={() => track("text_us_clicked", { location, campus: selectedCampus?.id, number_type: number ? "campus" : selectedCampus ? "selected" : "central" })}
aria-label={children ? undefined : "Text us a question"}
>
{children ?? (
<>
<Icon name="sms" /> Text Us
</>
)}
</a>
);
}
/** Persistent, unobtrusive floating action. Hidden while the form is on screen (the form has its own). */
export function FloatingTextUs() {
const [hidden, setHidden] = useState(false);
useEffect(() => {
const target = document.getElementById("plan");
if (!target || !("IntersectionObserver" in window)) return;
const io = new IntersectionObserver(([e]) => setHidden(e.isIntersecting), { threshold: 0.15 });
io.observe(target);
return () => io.disconnect();
}, []);
return (
<div
className={`fixed right-4 bottom-4 z-40 transition-[opacity,transform] duration-300 sm:right-6 sm:bottom-6 lg:hidden ${
hidden ? "pointer-events-none translate-y-3 opacity-0" : "opacity-100"
}`}
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<TextUsLink
location="floating"
className="btn btn-primary min-h-12 rounded-full px-5 text-sm shadow-teal"
>
<Icon name="sms" /> Text Us
</TextUsLink>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import type { ReactNode } from "react";
import { track, type AnalyticsEvent, type EventProps } from "@/lib/analytics";
interface Props {
href: string;
event: AnalyticsEvent;
props?: EventProps;
className?: string;
children: ReactNode;
target?: string;
rel?: string;
}
/** Anchor that fires an analytics event on click. Same-page anchors keep native scrolling. */
export function TrackedLink({ href, event, props, className, children, target, rel }: Props) {
return (
<a
href={href}
className={className}
target={target}
rel={rel}
onClick={() => track(event, props)}
>
{children}
</a>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { getCampus, type Campus } from "@/config/campuses";
import { site } from "@/config/site";
import { track } from "@/lib/analytics";
import { getAttribution, type Attribution } from "@/lib/attribution";
import type { LatLng } from "@/lib/geo";
export type SelectionSource = "recommended" | "precise" | "zip" | "manual" | "restored";
export interface Recommendation {
campus: Campus;
distanceMiles: number;
/** How the origin point was obtained. */
method: "approximate" | "precise" | "zip";
label?: string;
}
interface VisitState {
selectedCampus: Campus | null;
selectionSource: SelectionSource | null;
recommendation: Recommendation | null;
origin: LatLng | null;
/** Session attribution (captured on first call, refreshed if a new click id arrives). */
getAttribution: () => Attribution;
selectCampus: (id: string, source: SelectionSource) => void;
clearCampus: () => void;
setRecommendation: (r: Recommendation | null, origin: LatLng | null) => void;
/** Number to text: selected campus first, then the central number. */
smsNumber: string;
}
const VisitContext = createContext<VisitState | null>(null);
const STORAGE_KEY = "wc_visit_campus";
export function VisitProvider({ children }: { children: ReactNode }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectionSource, setSelectionSource] = useState<SelectionSource | null>(null);
const [recommendation, setRec] = useState<Recommendation | null>(null);
const [origin, setOrigin] = useState<LatLng | null>(null);
// Restore the campus chosen earlier in this session. This must run after hydration
// (the server-rendered HTML has no selection), which is why it is an effect.
useEffect(() => {
getAttribution(site.defaultVariant); // capture attribution on first paint
try {
const stored = window.sessionStorage.getItem(STORAGE_KEY);
if (stored && getCampus(stored)?.status === "open") {
// eslint-disable-next-line react-hooks/set-state-in-effect -- post-hydration restore from sessionStorage
setSelectedId(stored);
setSelectionSource("restored");
}
} catch {
/* ignore */
}
}, []);
const selectCampus = useCallback((id: string, source: SelectionSource) => {
const c = getCampus(id);
if (!c || c.status !== "open") return;
setSelectedId(id);
setSelectionSource(source);
try {
window.sessionStorage.setItem(STORAGE_KEY, id);
} catch {
/* ignore */
}
track("campus_selected", { campus: id, selection_source: source });
}, []);
const clearCampus = useCallback(() => {
setSelectedId(null);
setSelectionSource(null);
try {
window.sessionStorage.removeItem(STORAGE_KEY);
} catch {
/* ignore */
}
}, []);
const setRecommendation = useCallback((r: Recommendation | null, o: LatLng | null) => {
setRec(r);
setOrigin(o);
}, []);
const value = useMemo<VisitState>(() => {
const selectedCampus = getCampus(selectedId) ?? null;
return {
selectedCampus,
selectionSource,
recommendation,
origin,
getAttribution: () => getAttribution(site.defaultVariant),
selectCampus,
clearCampus,
setRecommendation,
smsNumber: selectedCampus?.smsNumber ?? site.centralSmsNumber,
};
}, [selectedId, selectionSource, recommendation, origin, selectCampus, clearCampus, setRecommendation]);
return <VisitContext.Provider value={value}>{children}</VisitContext.Provider>;
}
export function useVisit(): VisitState {
const ctx = useContext(VisitContext);
if (!ctx) throw new Error("useVisit must be used inside <VisitProvider>");
return ctx;
}
+33
View File
@@ -0,0 +1,33 @@
import { copy } from "@/content/copy";
import { faqs } from "@/content/faqs";
import { Container, Icon, SectionHeading } from "@/components/ui";
/**
* Native <details> accordion: works without JavaScript, fully keyboard accessible.
* The first two items start open so the section never reads as an empty list.
*/
export function WhatToExpect() {
return (
<section id="expect" className="bg-ink-2 py-16 text-paper sm:py-24" aria-labelledby="expect-heading">
<Container>
<div className="grid gap-10 lg:grid-cols-[1fr_1.4fr] lg:gap-16">
<div className="lg:sticky lg:top-24 lg:self-start">
<SectionHeading id="expect-heading" eyebrow="Step two" title={copy.expect.heading} sub={copy.expect.sub} />
<div className="stripe mt-8 hidden h-2 w-32 opacity-70 lg:block" aria-hidden="true" />
</div>
<dl className="divide-y divide-paper/10 border-y border-paper/10">
{faqs.map((f, i) => (
<details key={f.id} className="group" open={i < 2}>
<summary className="flex min-h-16 cursor-pointer list-none items-center justify-between gap-4 py-4 text-left font-display text-lg font-bold tracking-tight sm:text-xl">
<dt>{f.question}</dt>
<Icon name="chev" className="size-6 shrink-0 text-teal transition-transform duration-200 group-open:rotate-180" />
</summary>
<dd className="pb-6 text-base leading-relaxed text-mist sm:text-lg">{f.answer}</dd>
</details>
))}
</dl>
</div>
</Container>
</section>
);
}
+74
View File
@@ -0,0 +1,74 @@
import type { ReactNode } from "react";
export function Container({ children, className = "" }: { children: ReactNode; className?: string }) {
return <div className={`mx-auto w-full max-w-6xl px-5 sm:px-8 ${className}`}>{children}</div>;
}
export function Eyebrow({ children, className = "" }: { children: ReactNode; className?: string }) {
return <p className={`eyebrow ${className}`}>{children}</p>;
}
export function SectionHeading({
id,
eyebrow,
title,
sub,
tone = "dark",
}: {
id?: string;
eyebrow?: string;
title: string;
sub?: string;
tone?: "dark" | "light";
}) {
const subColor = tone === "dark" ? "text-mist" : "text-slate";
return (
<div className="max-w-2xl">
{eyebrow && <Eyebrow className="mb-3">{eyebrow}</Eyebrow>}
<h2 id={id} className="display-lg">
{title}
</h2>
{sub && <p className={`mt-4 text-lg leading-relaxed ${subColor}`}>{sub}</p>}
</div>
);
}
export function FieldError({ id, message }: { id: string; message?: string }) {
if (!message) return null;
return (
<p id={id} role="alert" className="mt-1.5 flex items-start gap-1.5 text-sm font-medium text-danger">
<svg aria-hidden="true" viewBox="0 0 20 20" className="mt-0.5 size-4 shrink-0 fill-current">
<path d="M10 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.75 4.5h1.5v5h-1.5v-5Zm.75 8.25a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z" />
</svg>
{message}
</p>
);
}
export function Spinner({ className = "size-5" }: { className?: string }) {
return (
<svg className={`animate-spin ${className}`} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-90" fill="currentColor" d="M4 12a8 8 0 0 1 8-8v3a5 5 0 0 0-5 5H4Z" />
</svg>
);
}
export function Icon({ name, className = "size-5" }: { name: "pin" | "sms" | "arrow" | "check" | "clock" | "phone" | "chev" | "target" | "x"; className?: string }) {
const paths: Record<string, string> = {
pin: "M12 22s7-6.1 7-12a7 7 0 1 0-14 0c0 5.9 7 12 7 12Zm0-9.5a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5Z",
sms: "M4 4h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-5 4v-4H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm3 5.5v1.5h10V9.5H7Zm0 3.5v1.5h6V13H7Z",
arrow: "M13 5l7 7-7 7-1.4-1.4 4.6-4.6H4v-2h12.2l-4.6-4.6L13 5Z",
check: "M9.5 16.2 5.3 12l-1.4 1.4 5.6 5.6 12-12L20.1 5.6 9.5 16.2Z",
clock: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 5h-2v6l5 3 1-1.7-4-2.4V7Z",
phone: "M6.6 10.8a15 15 0 0 0 6.6 6.6l2.2-2.2a1 1 0 0 1 1-.25 11.4 11.4 0 0 0 3.6.57 1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57a1 1 0 0 1-.25 1L6.6 10.8Z",
chev: "M7.4 8.6 12 13.2l4.6-4.6L18 10l-6 6-6-6 1.4-1.4Z",
target: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm9 3h-2.07A7 7 0 0 0 13 5.07V3h-2v2.07A7 7 0 0 0 5.07 11H3v2h2.07A7 7 0 0 0 11 18.93V21h2v-2.07A7 7 0 0 0 18.93 13H21v-2Zm-9 6a5 5 0 1 1 0-10 5 5 0 0 1 0 10Z",
x: "M18.3 5.7 12 12l6.3 6.3-1.4 1.4L12 13.4l-6.3 6.3-1.4-1.4L10.6 12 4.3 5.7l1.4-1.4L12 10.6l6.3-6.3 1.4 1.4Z",
};
return (
<svg viewBox="0 0 24 24" className={`${className} fill-current`} aria-hidden="true">
<path d={paths[name]} />
</svg>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { z } from "zod";
import raw from "../../config/campuses.json";
const e164 = z.string().regex(/^\+[1-9]\d{6,14}$/, "Must be E.164, e.g. +15085551234");
export const serviceTimeSchema = z.object({
day: z.string().min(1),
times: z.array(z.string().min(1)).min(1),
note: z.string().nullable().optional(),
});
export const campusSchema = z.object({
id: z.string().regex(/^[a-z0-9-]+$/),
name: z.string().min(1),
label: z.string().min(1),
status: z.enum(["open", "launching", "hidden"]),
isCentral: z.boolean().default(false),
address: z.object({
line1: z.string().min(1),
city: z.string().min(1),
state: z.string().length(2),
postalCode: z.string().min(3),
}),
coordinates: z.object({ lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180) }),
timezone: z.string().default("America/New_York"),
serviceTimes: z.array(serviceTimeSchema).min(1),
phone: e164,
smsNumber: e164.nullable(),
sinchAccountId: z.string().nullable(),
email: z.string().nullable(),
campusUrl: z.url(),
directionsUrl: z.url(),
leader: z.object({ title: z.string(), name: z.string() }).nullable().optional(),
arrivalNotes: z.string().nullable(),
parkingNotes: z.string().nullable(),
kidsNotes: z.string().nullable(),
image: z.string().nullable(),
verify: z.array(z.string()).default([]),
});
export type Campus = z.infer<typeof campusSchema>;
export type ServiceTime = z.infer<typeof serviceTimeSchema>;
const parsed = z.object({ campuses: z.array(campusSchema).min(1) }).parse(raw);
/** Every campus that is not hidden, in config order. */
export const campuses: readonly Campus[] = parsed.campuses.filter((c) => c.status !== "hidden");
/** Campuses a visitor can actually plan a visit to. */
export const openCampuses: readonly Campus[] = campuses.filter((c) => c.status === "open");
export const campusIds = openCampuses.map((c) => c.id) as [string, ...string[]];
export function getCampus(id: string | null | undefined): Campus | undefined {
if (!id) return undefined;
return campuses.find((c) => c.id === id);
}
export const centralCampus: Campus = campuses.find((c) => c.isCentral) ?? campuses[0];
export function formatAddress(c: Campus, multiline = false): string {
const a = c.address;
return multiline
? `${a.line1}\n${a.city}, ${a.state} ${a.postalCode}`
: `${a.line1}, ${a.city}, ${a.state} ${a.postalCode}`;
}
/** "7:30, 9:30 & 11:30 AM" style compression for a list of times sharing a meridiem. */
export function formatTimes(times: readonly string[]): string {
if (times.length === 1) return times[0];
const meridiems = new Set(times.map((t) => t.split(" ")[1]));
const bare = times.map((t) => t.split(" ")[0]);
const list = (arr: string[]) => (arr.length <= 1 ? arr.join("") : `${arr.slice(0, -1).join(", ")} & ${arr[arr.length - 1]}`);
if (meridiems.size === 1) return `${list(bare)} ${[...meridiems][0]}`;
return list([...times]);
}
/** Options for the "which service" select on the form. */
export function serviceOptions(c: Campus): { value: string; label: string }[] {
return c.serviceTimes.flatMap((st) =>
st.times.map((t) => ({ value: `${st.day} ${t}`, label: `${st.day} · ${t}` })),
);
}
+27
View File
@@ -0,0 +1,27 @@
import manifest from "./images.manifest.json";
export type ImageKey = keyof typeof manifest;
export interface ImageEntry {
width: number;
height: number;
widths: number[];
formats: string[];
blur?: string;
}
export const images = manifest as Record<ImageKey, ImageEntry>;
/** Campus id → image key. Falls back to the hero image when a campus has no photo. */
export function campusImageKey(imageName: string | null): ImageKey {
const key = `campus-${imageName ?? ""}` as ImageKey;
return key in images ? key : "hero-worship";
}
export const logos = {
markWhite: "/images/waters-mark-white.svg",
wordmarkWhite: "/images/waters-church-wordmark-white.svg",
markTeal512: "/images/waters-mark-teal-512.png",
markTeal192: "/images/waters-mark-teal-192.png",
markTeal96: "/images/waters-mark-teal-96.png",
} as const;
+63
View File
@@ -0,0 +1,63 @@
/**
* Site-wide settings. Anything marked NEXT_PUBLIC_* can be overridden in .env
* at build time. Values here are safe to ship to the browser.
*/
const env = (key: string, fallback = ""): string => process.env[key] ?? fallback;
export const site = {
name: "Waters Church",
shortName: "Waters",
tagline: "One church. Multiple locations across Massachusetts, Rhode Island, and Florida.",
url: env("NEXT_PUBLIC_SITE_URL", "https://visit.waterschurch.org").replace(/\/$/, ""),
mainSiteUrl: "https://waterschurch.org",
locationsUrl: "https://waterschurch.org/locations/",
kidsUrl: "https://waterschurch.org/about/waters-kids/",
beliefsUrl: "https://waterschurch.org/about/beliefs/",
watchUrl: "https://waterschurch.org/w/",
privacyUrl: env("NEXT_PUBLIC_PRIVACY_URL", "https://waterschurch.org/privacy/"),
// TODO(Waters Church): no Terms page was found on waterschurch.org. Set NEXT_PUBLIC_TERMS_URL when one exists.
termsUrl: env("NEXT_PUBLIC_TERMS_URL", "") || null,
email: "info@waterschurch.org",
/** Main office (North Attleboro). Verified on waterschurch.org. */
phone: "+15086951300",
phoneDisplay: "(508) 695-1300",
/**
* Number used for "Text Us" before a campus is chosen. Must be an SMS-enabled
* number on the Waters Church Sinch Engage account. Defaults to the main
* office number, which is SMS-enabled on the North Attleboro sub-account.
* TODO(Waters Church): confirm this is the number that should receive general questions.
*/
centralSmsNumber: env("NEXT_PUBLIC_CENTRAL_SMS_NUMBER", "+15086951300"),
officeHours: "Mon – Thu, 9:00 AM – 4:30 PM",
address: {
line1: "57 John L Dietsch Square",
city: "North Attleboro",
state: "MA",
postalCode: "02763",
country: "US",
},
social: {
facebook: "https://www.facebook.com/waterschurch",
instagram: "https://www.instagram.com/waterschurch",
youtube: "https://www.youtube.com/@waterschurch",
},
/** Pre-filled SMS text. Visitors can edit before sending. */
textUsMessage: "Hi! I'm thinking about visiting Waters Church and have a question.",
/** API base. Same origin in production; php -S in local dev (see README). */
apiBase: env("NEXT_PUBLIC_API_BASE", "/api"),
analytics: {
/** "implied" fires events immediately (typical US ad landing page). "opt-in" queues until consent is granted. */
consentMode: (env("NEXT_PUBLIC_ANALYTICS_CONSENT_MODE", "implied") as "implied" | "opt-in"),
gtmId: env("NEXT_PUBLIC_GTM_ID"),
ga4Id: env("NEXT_PUBLIC_GA4_ID"),
metaPixelId: env("NEXT_PUBLIC_META_PIXEL_ID"),
tiktokPixelId: env("NEXT_PUBLIC_TIKTOK_PIXEL_ID"),
googleAdsConversion: env("NEXT_PUBLIC_GADS_CONVERSION"), // e.g. AW-123456789/AbC-D_efG-h12_34-567
},
turnstileSiteKey: env("NEXT_PUBLIC_TURNSTILE_SITE_KEY"),
robotsIndex: env("NEXT_PUBLIC_ROBOTS_INDEX", "true") !== "false",
/** Landing page variant label sent with attribution (override per ad set with ?v=). */
defaultVariant: env("NEXT_PUBLIC_VARIANT", "a"),
} as const;
export type Site = typeof site;
+24
View File
@@ -0,0 +1,24 @@
/**
* Consent disclosures. The version string is stored with every lead so the
* exact language a visitor agreed to can be reproduced later.
*
* ⚠️ Legal review required: TCPA/CTIA SMS language, CAN-SPAM, and the privacy
* notice must be reviewed by Waters Church's compliance adviser before launch.
* Bump `version` whenever any text below changes.
*/
export const consent = {
version: "2026-09-22.v1",
email: {
label: "Email me a first-visit guide and occasional updates from Waters Church.",
detail: "You can unsubscribe with one click at any time.",
},
sms: {
label: "Text me a confirmation and reminders about my visit.",
detail:
"By checking this box you agree to receive text messages from Waters Church about your visit, including a confirmation text. Message frequency varies. Message and data rates may apply. Reply STOP to opt out or HELP for help. Consent is not a condition of visiting.",
},
privacyBlurb:
"We use your information to help you plan your visit. We never sell it.",
locationBlurb:
"We estimate your general area from your network connection to suggest a location. Precise location is only used if you ask for it and is never stored.",
} as const;
+65
View File
@@ -0,0 +1,65 @@
/** Landing-page copy. Keep headlines short, direct, and free of insider language. */
export const copy = {
header: { findCampus: "Find Your Campus", textUs: "Text Us" },
hero: {
eyebrow: "Plan your first visit",
headline: ["Church can feel", "different here."],
sub: "Your first visit doesn't have to feel uncertain. We'll help you know what to expect and make it easy to find the Waters Church location nearest you.",
primaryCta: "Plan My Visit",
secondaryCta: "Find My Campus",
textCta: "Questions? Text us",
trust: "No pressure. Just a friendly welcome.",
},
campus: {
heading: "Find your closest location",
sub: "Six locations across Massachusetts, Rhode Island, and Florida. Pick the one that works for you.",
recommended: "Closest to you (based on your general area)",
recommendedPrecise: "Closest to you",
uncertain: "We couldn't estimate your location. Choose a campus below or search by ZIP.",
useLocation: "Use my precise location",
locating: "Finding nearby locations…",
zipLabel: "Search by ZIP code",
zipPlaceholder: "e.g. 02763",
zipButton: "Search",
choose: "Choose This Campus",
chosen: "Your Campus",
textCampus: "Text This Campus",
directions: "Get Directions",
change: "Choose a different location",
all: "All locations",
launching: "Coming soon",
denied: "No problem. Pick your location below or search by ZIP.",
unsupported: "Your browser doesn't share location. Pick your location below or search by ZIP.",
farAway: "Our closest location is a bit of a drive. You're still welcome, and you can also join us online.",
},
expect: {
heading: "What to expect",
sub: "Real answers to the questions people actually ask before their first visit.",
},
kids: {
heading: "Bringing kids?",
sub: "Waters Kids is built so your kids have a great time and you can actually relax.",
},
proof: { heading: "What first-time visitors say" },
form: {
heading: "Plan your visit",
sub: "Tell us a little about you and we'll help you feel ready. Takes about a minute.",
steps: ["Your campus", "About you", "Your visit"],
next: "Continue",
back: "Back",
submit: "Send My Visit Details",
submitting: "Sending…",
},
confirm: {
heading: "You're all set.",
sub: "We can't wait to meet you.",
emailNote: "Check your inbox. A first-visit guide is on the way.",
smsNote: "We've sent a confirmation text. Reply to it any time with questions.",
smsPending: "Your confirmation text may take a minute to arrive.",
textUs: "Text Us a Question",
again: "Plan another visit",
},
footer: {
online: "Can't make it in person? Join us online.",
},
} as const;
+65
View File
@@ -0,0 +1,65 @@
/**
* "What to Expect" content. Short, scannable, newcomer-first.
* Items flagged `confirm: true` contain general guidance that Waters Church
* should review for campus-specific accuracy before launch.
*/
export interface Faq {
id: string;
question: string;
answer: string;
confirm?: boolean;
}
export const faqs: Faq[] = [
{
id: "wear",
question: "What should I wear?",
answer:
"Whatever you're comfortable in. Most people show up in jeans and a T-shirt or something casual. Nobody is checking.",
},
{
id: "length",
question: "How long is a service?",
answer:
"Plan on a little over an hour. There's music, a message you can actually use during the week, and then you're free to go or stick around and say hi.",
confirm: true,
},
{
id: "music",
question: "What is the music and teaching like?",
answer:
"The music is loud, modern, and led by a live band. The teaching is straight from the Bible, practical, and explained in plain language, even if you've never opened one.",
},
{
id: "parking",
question: "Where should I park?",
answer:
"Every location has free on-site parking. Look for the team in Waters Church gear near the entrance if you're not sure where to go.",
confirm: true,
},
{
id: "arrive",
question: "What happens when I arrive?",
answer:
"Someone will say hello and point you in the right direction. Grab a coffee, find a seat anywhere you like, and settle in. If you have kids, the check-in team will get them set up first.",
},
{
id: "kids",
question: "Is there something for my kids?",
answer:
"Yes. Waters Kids runs during every service at every location, with age-specific rooms from newborns through middle school. Every volunteer is background-checked, and a security tag system covers check-in and pickup.",
},
{
id: "pressure",
question: "Will I be singled out or pressured?",
answer:
"No. You won't be asked to stand, speak, give, or fill anything out. Come as an observer for as long as you like. If you want to take a next step, we'll make it easy to find. If not, that's fine too.",
},
{
id: "accessible",
question: "Is the venue accessible?",
answer:
"Our locations have step-free entrances and accessible seating. If you or someone with you has a specific need, text us ahead of time and we'll be ready.",
confirm: true,
},
];
+73
View File
@@ -0,0 +1,73 @@
/**
* Waters Kids information. Age groups and safety facts are taken from
* waterschurch.org/about/waters-kids (verified 2026-09-22). Items marked
* `placeholder: true` are NOT verified policies and must be confirmed or
* removed by Waters Church before launch.
*/
export const kidsAgeGroups = [
{ id: "0-2", label: "Early Development", ages: "0–2 yrs", rooms: "Blue & Purple Rooms" },
{ id: "3-5", label: "Preschool & Pre-K", ages: "3–5 yrs", rooms: "Green, Yellow & Orange Rooms" },
{ id: "6-10", label: "Elementary", ages: "6–10 yrs", rooms: "Red Room & Ignite" },
{ id: "11-13", label: "Middle School & Pre-Teen", ages: "11–13 yrs", rooms: "Activate Youth Group" },
] as const;
export type KidsAgeRangeId = (typeof kidsAgeGroups)[number]["id"] | "14-18";
/** Age ranges offered in the form (includes high school for planning purposes). */
export const childAgeRangeOptions: { value: KidsAgeRangeId; label: string }[] = [
{ value: "0-2", label: "0–2" },
{ value: "3-5", label: "3–5" },
{ value: "6-10", label: "6–10" },
{ value: "11-13", label: "11–13" },
{ value: "14-18", label: "14–18 (Waters Youth)" },
];
export interface KidsPoint {
id: string;
title: string;
body: string;
placeholder?: boolean;
}
export const kidsPoints: KidsPoint[] = [
{
id: "checkin",
title: "Check-in",
body:
"Head to the Waters Kids check-in area when you arrive. A team member will register your family, print name tags for your kids, and give you a matching security tag for pickup.",
placeholder: true,
},
{
id: "safety",
title: "Safety & security",
body:
"Every Waters Kids volunteer passes a background check. Waters Church also employs its own security team, uses a security tag system for check-in and pickup, and can page you during the service if your child needs you.",
},
{
id: "ages",
title: "Age groups",
body:
"Kids are grouped by age from newborn through middle school, with programs running at every service time and location. High schoolers are invited to Waters Youth.",
},
{
id: "arrive",
title: "When to arrive",
body:
"For a relaxed first visit, plan to arrive about 15 minutes before the service starts so check-in isn't rushed.",
placeholder: true,
},
{
id: "bring",
title: "What to bring",
body:
"Just your kids. If your little one needs diapers, a bottle, or a snack, label them and hand them to the room leader at check-in.",
placeholder: true,
},
{
id: "needs",
title: "Special needs & accessibility",
body:
"If your child has a special need, allergy, or anything the team should know, tell us at check-in or text ahead of your visit. [Waters Church: confirm available accommodations before launch.]",
placeholder: true,
},
];
+19
View File
@@ -0,0 +1,19 @@
/**
* Social proof. No real testimonials were available in the repository or on
* waterschurch.org at build time, so this list is EMPTY and the section is
* hidden automatically. To enable it, add verified quotes with permission:
*
* { quote: "…", name: "First L.", context: "First visited 2025 · Norwood" }
*
* Never invent quotes, attendance numbers, or ratings.
*/
export interface Testimonial {
quote: string;
name: string;
context?: string;
}
export const testimonials: Testimonial[] = [];
/** When true and `testimonials` is empty, a visible placeholder card renders in dev/preview builds only. */
export const showTestimonialPlaceholderInDev = true;
+127
View File
@@ -0,0 +1,127 @@
import { site } from "@/config/site";
import { getAttribution, inferPlatform } from "@/lib/attribution";
export type AnalyticsEvent =
| "landing_page_view"
| "campus_recommended"
| "location_permission_requested"
| "campus_selected"
| "plan_visit_started"
| "plan_visit_step_completed"
| "plan_visit_submitted"
| "plan_visit_success"
| "plan_visit_error"
| "text_us_clicked"
| "directions_clicked";
export type EventProps = Record<string, string | number | boolean | undefined>;
declare global {
interface Window {
dataLayer?: unknown[];
gtag?: (...args: unknown[]) => void;
fbq?: (...args: unknown[]) => void;
ttq?: { track: (name: string, props?: Record<string, unknown>) => void; page?: () => void };
}
}
const CONSENT_KEY = "wc_analytics_consent";
let queue: { event: AnalyticsEvent; props: EventProps }[] = [];
export function analyticsConsentGranted(): boolean {
if (site.analytics.consentMode === "implied") return true;
try {
return typeof window !== "undefined" && window.localStorage.getItem(CONSENT_KEY) === "granted";
} catch {
return false;
}
}
export function analyticsConsentDecided(): boolean {
if (site.analytics.consentMode === "implied") return true;
try {
const v = window.localStorage.getItem(CONSENT_KEY);
return v === "granted" || v === "denied";
} catch {
return true;
}
}
export function grantAnalyticsConsent(): void {
try {
window.localStorage.setItem(CONSENT_KEY, "granted");
} catch {
/* ignore */
}
const pending = queue;
queue = [];
pending.forEach((p) => dispatch(p.event, p.props));
}
export function denyAnalyticsConsent(): void {
try {
window.localStorage.setItem(CONSENT_KEY, "denied");
} catch {
/* ignore */
}
queue = [];
}
export function deviceCategory(): "mobile" | "tablet" | "desktop" {
if (typeof window === "undefined") return "desktop";
const w = window.innerWidth;
return w < 640 ? "mobile" : w < 1024 ? "tablet" : "desktop";
}
/** Base properties attached to every event. Never include PII here. */
export function baseProps(): EventProps {
const a = getAttribution(site.defaultVariant);
return {
variant: a.variant,
source: a.utm_source,
medium: a.utm_medium,
campaign: a.utm_campaign,
content: a.utm_content,
platform: inferPlatform(a),
device_category: deviceCategory(),
};
}
function dispatch(event: AnalyticsEvent, props: EventProps): void {
const payload = { event, ...baseProps(), ...props };
// 1) GTM / generic dataLayer (works with any tag manager)
window.dataLayer = window.dataLayer ?? [];
window.dataLayer.push(payload);
// 2) Direct GA4 if configured without GTM
if (site.analytics.ga4Id && typeof window.gtag === "function") {
window.gtag("event", event, payload);
}
// 3) Conversion pixels fire only on the server-confirmed success event
if (event === "plan_visit_success") {
if (site.analytics.googleAdsConversion && typeof window.gtag === "function") {
window.gtag("event", "conversion", { send_to: site.analytics.googleAdsConversion });
}
if (site.analytics.metaPixelId && typeof window.fbq === "function") {
window.fbq("track", "Lead", { content_name: "plan_visit", content_category: String(props.campus ?? "") });
}
if (site.analytics.tiktokPixelId && window.ttq) {
window.ttq.track("SubmitForm", { content_name: "plan_visit" });
}
}
// 4) Local hook for tests / custom listeners
window.dispatchEvent(new CustomEvent("wc:analytics", { detail: payload }));
}
export function track(event: AnalyticsEvent, props: EventProps = {}): void {
if (typeof window === "undefined") return;
if (!analyticsConsentGranted()) {
queue.push({ event, props });
return;
}
dispatch(event, props);
}
/** For tests. */
export function _resetAnalyticsQueue(): void {
queue = [];
}
+79
View File
@@ -0,0 +1,79 @@
import { site } from "@/config/site";
import type { PlanVisitData } from "@/lib/schema";
export interface LocateResponse {
ok: boolean;
approximate?: { lat: number; lng: number; city?: string; region?: string; confidence: "low" | "medium" };
reason?: string;
}
export interface GeocodeResponse {
ok: boolean;
location?: { lat: number; lng: number; city?: string; state?: string };
error?: string;
}
export type IntegrationStatus = "queued" | "sent" | "skipped" | "failed" | "mock";
export interface SubmitResponse {
ok: boolean;
submissionId?: string;
duplicate?: boolean;
email?: { status: IntegrationStatus };
sms?: { status: IntegrationStatus };
error?: string;
fieldErrors?: Record<string, string>;
retryAfter?: number;
}
const DEFAULT_TIMEOUT = 10_000;
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
async function request<T>(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), init.timeoutMs ?? DEFAULT_TIMEOUT);
try {
const res = await fetch(`${site.apiBase}${path}`, {
...init,
signal: controller.signal,
headers: { Accept: "application/json", "X-Requested-With": "visit-page", ...(init.headers ?? {}) },
credentials: "same-origin",
});
const text = await res.text();
let data: unknown = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
throw new ApiError(`Bad response (${res.status})`, res.status);
}
if (!res.ok && (!data || typeof data !== "object")) throw new ApiError(`Request failed (${res.status})`, res.status);
return data as T;
} finally {
clearTimeout(t);
}
}
/** Approximate, server-side IP geolocation. Never blocks the page. */
export function locateApprox(): Promise<LocateResponse> {
return request<LocateResponse>("/locate.php", { method: "GET", timeoutMs: 4000 });
}
export function geocodeZip(zip: string): Promise<GeocodeResponse> {
return request<GeocodeResponse>(`/geocode.php?zip=${encodeURIComponent(zip)}`, { method: "GET", timeoutMs: 8000 });
}
export function submitPlanVisit(data: PlanVisitData): Promise<SubmitResponse> {
return request<SubmitResponse>("/plan-visit.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
timeoutMs: 20_000,
});
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Advertising attribution captured once per browser session and carried
* through every step of the form (campus changes, step changes, retries).
*/
export const ATTRIBUTION_KEYS = [
"utm_source",
"utm_medium",
"utm_campaign",
"utm_content",
"utm_term",
"gclid",
"gbraid",
"wbraid",
"fbclid",
"ttclid",
"twclid",
] as const;
export type AttributionKey = (typeof ATTRIBUTION_KEYS)[number];
export interface Attribution extends Partial<Record<AttributionKey, string>> {
variant: string;
landing_url: string;
referrer: string;
first_seen_at: string;
}
const STORAGE_KEY = "wc_visit_attr";
const MAX_LEN = 200;
const clean = (v: string | null): string | undefined => {
if (!v) return undefined;
const t = v.trim().slice(0, MAX_LEN);
// strip control characters and anything that looks like markup
return t.replace(/[\x00-\x1f<>"'`]/g, "") || undefined;
};
export function parseAttribution(url: string, referrer: string, now: Date, defaultVariant: string): Attribution {
let params: URLSearchParams;
try {
params = new URL(url).searchParams;
} catch {
params = new URLSearchParams();
}
const out: Attribution = {
variant: clean(params.get("v") ?? params.get("variant")) ?? defaultVariant,
landing_url: url.slice(0, 2000),
referrer: referrer.slice(0, 500),
first_seen_at: now.toISOString(),
};
for (const k of ATTRIBUTION_KEYS) {
const v = clean(params.get(k));
if (v) out[k] = v;
}
// TikTok sometimes passes tt_click_id instead of ttclid.
const tt = clean(params.get("tt_click_id"));
if (tt && !out.ttclid) out.ttclid = tt;
return out;
}
function storage(): Storage | null {
try {
return typeof window !== "undefined" ? window.sessionStorage : null;
} catch {
return null;
}
}
/** Returns stored attribution, or captures it from the current URL on first call. */
export function getAttribution(defaultVariant = "a"): Attribution {
if (typeof window === "undefined") {
return { variant: defaultVariant, landing_url: "", referrer: "", first_seen_at: new Date().toISOString() };
}
const s = storage();
const fresh = parseAttribution(window.location.href, document.referrer, new Date(), defaultVariant);
if (s) {
try {
const raw = s.getItem(STORAGE_KEY);
if (raw) {
const stored = JSON.parse(raw) as Attribution;
// If a *new* click id/utm arrives mid-session (user clicked another ad), refresh those keys.
const hasNewIds = ATTRIBUTION_KEYS.some((k) => fresh[k] && fresh[k] !== stored[k]);
// An explicit ?v= on this page load wins over the variant captured earlier in the session.
const hasNewVariant = fresh.variant !== defaultVariant && fresh.variant !== stored.variant;
if (!hasNewIds && !hasNewVariant) return stored;
const merged: Attribution = {
...stored,
...fresh,
first_seen_at: stored.first_seen_at,
referrer: stored.referrer || fresh.referrer,
landing_url: stored.landing_url || fresh.landing_url,
};
s.setItem(STORAGE_KEY, JSON.stringify(merged));
return merged;
}
s.setItem(STORAGE_KEY, JSON.stringify(fresh));
} catch {
/* storage unavailable: fall through */
}
}
return fresh;
}
/** Platform label derived from attribution, used for analytics props and Mailchimp tags. */
export function inferPlatform(a: Partial<Attribution>): string {
const src = (a.utm_source ?? "").toLowerCase();
if (a.gclid || a.gbraid || a.wbraid || /google|adwords/.test(src)) return "google";
if (/instagram|^ig$/.test(src)) return "instagram";
if (a.fbclid || /facebook|^fb$|meta/.test(src)) return "facebook";
if (a.ttclid || /tiktok/.test(src)) return "tiktok";
if (a.twclid || /twitter|^x$|x\.com/.test(src)) return "x";
return src || "direct";
}
/** Flatten to a string map for transport to the API. */
export function attributionForApi(a: Attribution): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(a)) if (typeof v === "string" && v) out[k] = v;
return out;
}
+67
View File
@@ -0,0 +1,67 @@
import type { Campus } from "@/config/campuses";
export interface LatLng {
lat: number;
lng: number;
}
const EARTH_RADIUS_MILES = 3958.7613;
export function haversineMiles(a: LatLng, b: LatLng): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS_MILES * Math.asin(Math.min(1, Math.sqrt(h)));
}
export interface RankedCampus {
campus: Campus;
distanceMiles: number;
}
/** Sort open campuses by distance from a point. */
export function rankCampuses(origin: LatLng, list: readonly Campus[]): RankedCampus[] {
return list
.filter((c) => c.status === "open")
.map((campus) => ({ campus, distanceMiles: haversineMiles(origin, campus.coordinates) }))
.sort((a, b) => a.distanceMiles - b.distanceMiles);
}
export function nearestCampus(origin: LatLng, list: readonly Campus[]): RankedCampus | undefined {
return rankCampuses(origin, list)[0];
}
/** Beyond this distance we say "a bit of a drive" and suggest online. */
export const FAR_AWAY_MILES = 75;
export function formatDistance(miles: number): string {
if (miles < 0.95) return "under a mile";
if (miles < 10) return `${miles.toFixed(1)} mi`;
return `${Math.round(miles)} mi`;
}
export function isValidLatLng(p: unknown): p is LatLng {
if (!p || typeof p !== "object") return false;
const { lat, lng } = p as Record<string, unknown>;
return (
typeof lat === "number" &&
typeof lng === "number" &&
Number.isFinite(lat) &&
Number.isFinite(lng) &&
lat >= -90 &&
lat <= 90 &&
lng >= -180 &&
lng <= 180 &&
!(lat === 0 && lng === 0)
);
}
export const US_ZIP_RE = /^\d{5}(?:-\d{4})?$/;
export function normalizeZip(input: string): string | null {
const z = input.trim();
return US_ZIP_RE.test(z) ? z.slice(0, 5) : null;
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Lightweight North American (NANP) phone normalization for the browser.
* The PHP API performs authoritative validation before anything is stored or sent.
*/
export function normalizeNanpPhone(input: string): string | null {
const digits = input.replace(/\D/g, "");
let national = digits;
if (digits.length === 11 && digits.startsWith("1")) national = digits.slice(1);
if (national.length !== 10) return null;
const area = national.slice(0, 3);
const exchange = national.slice(3, 6);
// NANP rules: area code and exchange cannot start with 0 or 1; N11 area codes are reserved.
if (/^[01]/.test(area) || /^[01]/.test(exchange)) return null;
if (area[1] === "1" && area[2] === "1") return null;
return `+1${national}`;
}
export function formatNanpDisplay(e164: string): string {
const m = /^\+1(\d{3})(\d{3})(\d{4})$/.exec(e164);
return m ? `(${m[1]}) ${m[2]}-${m[3]}` : e164;
}
/** Build an sms: link that works on iOS and Android. */
export function smsLink(number: string, body: string): string {
const encoded = encodeURIComponent(body);
// iOS accepts "sms:+1555...&body=", Android accepts "sms:+1555...?body=". The "?&" form is tolerated by both.
return `sms:${number}?&body=${encoded}`;
}
+75
View File
@@ -0,0 +1,75 @@
import { z } from "zod";
import { campusIds } from "@/config/campuses";
import { normalizeNanpPhone } from "@/lib/phone";
const trimmed = (max: number) => z.string().trim().max(max);
export const childAgeRanges = ["0-2", "3-5", "6-10", "11-13", "14-18"] as const;
export function todayIsoDate(now: Date = new Date()): string {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
export const planVisitSchema = z
.object({
submissionId: z.uuid(),
firstName: trimmed(60).min(1, "Please tell us your first name."),
lastName: trimmed(60).optional().or(z.literal("")),
email: z.email("That email doesn't look right.").max(254),
phone: trimmed(30).optional().or(z.literal("")),
campusId: z.enum(campusIds, { message: "Please choose a campus." }),
visitDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Use the date picker.")
.optional()
.or(z.literal("")),
serviceTime: trimmed(80).optional().or(z.literal("")),
adults: z.number().int().min(1).max(20).optional(),
children: z.number().int().min(0).max(10).optional(),
childAgeRanges: z.array(z.enum(childAgeRanges)).max(5).optional(),
questions: trimmed(1000).optional().or(z.literal("")),
emailConsent: z.boolean(),
smsConsent: z.boolean(),
consentVersion: z.string().min(1),
/** Honeypot: must stay empty. */
website: z.literal("").optional(),
startedAt: z.number().int().positive(),
attribution: z.record(z.string(), z.string()).optional(),
turnstileToken: z.string().max(4000).optional(),
})
.superRefine((v, ctx) => {
if (v.smsConsent) {
const e164 = v.phone ? normalizeNanpPhone(v.phone) : null;
if (!e164) {
ctx.addIssue({
code: "custom",
path: ["phone"],
message: "Add a mobile number so we can text you, or uncheck texting.",
});
}
} else if (v.phone && v.phone.trim() && !normalizeNanpPhone(v.phone)) {
ctx.addIssue({ code: "custom", path: ["phone"], message: "That phone number doesn't look right." });
}
if (v.visitDate && v.visitDate < todayIsoDate()) {
ctx.addIssue({ code: "custom", path: ["visitDate"], message: "Pick today or a future date." });
}
});
export type PlanVisitInput = z.input<typeof planVisitSchema>;
export type PlanVisitData = z.output<typeof planVisitSchema>;
/** Which fields each step of the progressive form validates. */
export const stepFields: Record<number, (keyof PlanVisitInput)[]> = {
0: ["campusId"],
1: ["firstName", "lastName", "email", "phone", "emailConsent", "smsConsent"],
2: ["visitDate", "serviceTime", "adults", "children", "childAgeRanges", "questions"],
};
export function issuesToMap(issues: readonly z.core.$ZodIssue[]): Record<string, string> {
const out: Record<string, string> = {};
for (const i of issues) {
const key = String(i.path[0] ?? "_form");
if (!out[key]) out[key] = i.message;
}
return out;
}