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>
@@ -0,0 +1,110 @@
|
||||
# =============================================================================
|
||||
# visit.waterschurch.org — environment configuration (PLACEHOLDERS ONLY)
|
||||
#
|
||||
# Two consumers read this file format:
|
||||
# 1. `npm run build` (Next.js) reads NEXT_PUBLIC_* from ./.env at build time.
|
||||
# These values are embedded in the static HTML/JS — never put secrets here.
|
||||
# 2. The PHP API reads EVERYTHING ELSE at runtime from
|
||||
# <VISIT_PRIVATE_DIR>/.env on the server (outside the web root).
|
||||
# Copy this file there as `.env` and fill in real values.
|
||||
# =============================================================================
|
||||
|
||||
# ---- Public site ------------------------------------------------------------
|
||||
NEXT_PUBLIC_SITE_URL=https://visit.waterschurch.org
|
||||
PUBLIC_SITE_URL=https://visit.waterschurch.org
|
||||
# Comma-separated extra origins allowed to call the API (local dev only)
|
||||
ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
# true = search engines may index. Set false on staging.
|
||||
NEXT_PUBLIC_ROBOTS_INDEX=true
|
||||
# Where the browser calls the API. Same-origin "/api" in production; php -S in dev.
|
||||
NEXT_PUBLIC_API_BASE=/api
|
||||
# Default landing-page variant label (override per ad with ?v=b)
|
||||
NEXT_PUBLIC_VARIANT=a
|
||||
# Legal pages
|
||||
NEXT_PUBLIC_PRIVACY_URL=https://waterschurch.org/privacy/
|
||||
NEXT_PUBLIC_TERMS_URL=
|
||||
# "Text Us" number before a campus is chosen (must be SMS-enabled on the Sinch account)
|
||||
NEXT_PUBLIC_CENTRAL_SMS_NUMBER=+15086951300
|
||||
|
||||
# ---- Integration mode -------------------------------------------------------
|
||||
# mock = log what WOULD be sent, send nothing (default). live = real Mailchimp + Sinch calls.
|
||||
INTEGRATION_MODE=mock
|
||||
LOG_LEVEL=info
|
||||
# Random string used to hash IPs for rate limiting / consent records. Generate: openssl rand -hex 32
|
||||
HASH_SALT=change-me-to-a-long-random-string
|
||||
# Set true ONLY if the site is behind Cloudflare or another trusted proxy
|
||||
TRUST_PROXY_HEADERS=false
|
||||
|
||||
# ---- Lead storage -----------------------------------------------------------
|
||||
# Default: SQLite at <VISIT_PRIVATE_DIR>/data/leads.sqlite (no setup needed).
|
||||
# To use cPanel MySQL instead, create a database + user in cPanel and set:
|
||||
DATABASE_DSN=
|
||||
# e.g. mysql:host=localhost;dbname=cpaneluser_visit;charset=utf8mb4
|
||||
DATABASE_USER=
|
||||
DATABASE_PASSWORD=
|
||||
# Override the data directory (must be writable by PHP and outside the web root)
|
||||
DATA_DIR=
|
||||
# Delete fully-processed leads older than N days (0 = keep forever). Confirm with Waters Church.
|
||||
LEAD_RETENTION_DAYS=0
|
||||
|
||||
# ---- Mailchimp --------------------------------------------------------------
|
||||
# Account → Extras → API keys. The key ends in "-usXX"; that suffix is the server prefix.
|
||||
MAILCHIMP_API_KEY=
|
||||
MAILCHIMP_SERVER_PREFIX=us21
|
||||
# Audience → Settings → Audience name and defaults → "Audience ID"
|
||||
MAILCHIMP_AUDIENCE_ID=
|
||||
# Optional: API-triggered Customer Journey (Automations → journey → API trigger step)
|
||||
MAILCHIMP_JOURNEY_ID=
|
||||
MAILCHIMP_JOURNEY_STEP_ID=
|
||||
# Tag prefix applied to every contact from this page
|
||||
MAILCHIMP_TAG_PREFIX=Visit:
|
||||
# true = new contacts start as "pending" and receive a Mailchimp confirmation email
|
||||
MAILCHIMP_DOUBLE_OPTIN=false
|
||||
|
||||
# ---- Sinch Engage (formerly MessageMedia) -----------------------------------
|
||||
# Sinch Engage portal → Settings → API credentials (Basic auth key + secret).
|
||||
# Regional host: US https://us.app.api.sinch.com | EU https://eu.app.api.sinch.com | APAC https://au.app.api.sinch.com
|
||||
SINCH_API_BASE=https://us.app.api.sinch.com
|
||||
SINCH_API_KEY=
|
||||
SINCH_API_SECRET=
|
||||
# Send from each campus's own number/sub-account (config/campuses.json). Recommended.
|
||||
SINCH_USE_CAMPUS_NUMBERS=true
|
||||
SINCH_USE_CAMPUS_SUBACCOUNTS=true
|
||||
# Fallback sender when a campus has no number (must be a 10DLC/toll-free number on the parent account)
|
||||
SINCH_DEFAULT_SOURCE_NUMBER=+17746436322
|
||||
# Fallback sub-account (leave blank to send as the parent account)
|
||||
SINCH_ACCOUNT_ID=
|
||||
# Optional staff heads-up text for every new lead (no visitor PII beyond first name)
|
||||
SINCH_INTERNAL_NOTIFY_NUMBER=
|
||||
# Optional override of the confirmation template. Placeholders: {{first_name}} {{campus_name}} {{service_or_date}} {{address}}
|
||||
SINCH_CONFIRMATION_TEMPLATE=
|
||||
|
||||
# ---- Location services ------------------------------------------------------
|
||||
# Approximate IP geolocation: none | header | ipapi_co | ipinfo | ip_api_com
|
||||
GEOIP_PROVIDER=ipapi_co
|
||||
GEOIP_API_KEY=
|
||||
GEOIP_CACHE_TTL=3600
|
||||
# ZIP lookup: zippopotam | nominatim | google
|
||||
GEOCODE_PROVIDER=zippopotam
|
||||
GEOCODE_API_KEY=
|
||||
# Local testing helpers (never enable in production)
|
||||
DEBUG_GEO=false
|
||||
|
||||
# ---- Anti-spam ----------------------------------------------------------------
|
||||
# Optional Cloudflare Turnstile (free). Leave blank to rely on honeypot + timing + rate limits.
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
MIN_FORM_SECONDS=3
|
||||
RATE_LIMIT_SUBMIT_PER_10MIN=5
|
||||
RATE_LIMIT_SUBMIT_PER_DAY=60
|
||||
RETRY_BATCH_SIZE=25
|
||||
|
||||
# ---- Analytics & advertising (all optional; nothing loads when blank) -------
|
||||
# implied = fire immediately (typical US ad landing page); opt-in = show a consent prompt first
|
||||
NEXT_PUBLIC_ANALYTICS_CONSENT_MODE=implied
|
||||
NEXT_PUBLIC_GTM_ID=
|
||||
NEXT_PUBLIC_GA4_ID=
|
||||
NEXT_PUBLIC_META_PIXEL_ID=
|
||||
NEXT_PUBLIC_TIKTOK_PIXEL_ID=
|
||||
# Google Ads conversion "send_to" value, e.g. AW-123456789/AbC-D_efG-h12_34-567
|
||||
NEXT_PUBLIC_GADS_CONVERSION=
|
||||
@@ -0,0 +1,25 @@
|
||||
# dependencies & build output
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
dist/
|
||||
tsconfig.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# generated at build time by scripts/optimize-images.mjs (run `npm run images`)
|
||||
public/images/
|
||||
src/config/images.manifest.json
|
||||
|
||||
# secrets & local runtime data (never commit)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
private/*
|
||||
!private/README.md
|
||||
public/api/data/
|
||||
tests/php/.tmp/
|
||||
|
||||
# misc
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage/
|
||||
@@ -0,0 +1,107 @@
|
||||
# visit.waterschurch.org — Plan Your Visit landing page
|
||||
|
||||
A focused, mobile-first advertising landing page for Waters Church that helps
|
||||
first-time visitors find their closest campus, know what to expect, and plan a
|
||||
visit. Built to deploy on the church's **cPanel/Apache** hosting as a static
|
||||
site with a small PHP API.
|
||||
|
||||
```
|
||||
Ads (Facebook · Instagram · X · Google · TikTok)
|
||||
│ utm_* / gclid / fbclid / ttclid / twclid
|
||||
▼
|
||||
┌──────────────────────────────┐ ┌────────────────────────────────────┐
|
||||
│ Static site (Next.js export)│ JSON │ PHP API (public/api) │
|
||||
│ hero · campus finder · │ ─────▶ │ validate → store lead (SQLite/MySQL)│
|
||||
│ what to expect · kids · form│ │ → Mailchimp → Sinch Engage → retry │
|
||||
└──────────────────────────────┘ └────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Audience |
|
||||
| --- | --- |
|
||||
| [docs/SETUP.md](docs/SETUP.md) | Developers: run locally, test without sending messages, configure Mailchimp / Sinch / tracking, edit campus content |
|
||||
| [docs/DEPLOY-CPANEL.md](docs/DEPLOY-CPANEL.md) | Site administrator: create the subdomain, SSL, upload, private config, cron, updates |
|
||||
| [docs/LAUNCH-CHECKLIST.md](docs/LAUNCH-CHECKLIST.md) | Everyone: post-deployment verification and the list of items that need human/legal approval |
|
||||
| [.env.example](.env.example) | Every configuration key, with placeholders |
|
||||
| [config/campuses.json](config/campuses.json) | Campus names, addresses, service times, SMS numbers (single source of truth) |
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # public build settings (safe to commit? no — .env is git-ignored)
|
||||
cp .env.example private/.env # local PHP API settings (INTEGRATION_MODE=mock)
|
||||
npm run dev # Next.js on http://localhost:3000
|
||||
npm run dev:api # PHP API on http://127.0.0.1:8080 (needs PHP 8.1+ locally)
|
||||
```
|
||||
|
||||
Point the frontend at the local API by setting `NEXT_PUBLIC_API_BASE=http://127.0.0.1:8080/api`
|
||||
in `.env` (and add `http://localhost:3000` to `ALLOWED_ORIGINS` in `private/.env`).
|
||||
|
||||
Or preview the production build in one process (static files + PHP together):
|
||||
|
||||
```bash
|
||||
npm run build && php -S 127.0.0.1:8080 -t out
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `npm run dev` | Next.js dev server |
|
||||
| `npm run dev:api` | PHP built-in server for the API (`public/`) |
|
||||
| `npm run build` | Optimize images → `next build` (static export) → post-build (`.htaccess`, campus config) → `out/` |
|
||||
| `npm run package` | Zip `out/` into `dist/` for upload through cPanel File Manager |
|
||||
| `npm test` | Vitest: distance, ZIP fallback, validation, consent, attribution, analytics, form flow (keyboard) |
|
||||
| `npm run test:php` | PHP suite: validation, Mailchimp/Sinch mapping, idempotency, partial failure + retry, HTTP end-to-end |
|
||||
| `npm run typecheck` / `npm run lint` | TypeScript and ESLint |
|
||||
| `npm run images` | Regenerate responsive AVIF/WebP/JPEG images from `assets/source` |
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
config/campuses.json campus data (read by TS and PHP)
|
||||
src/config/ site settings, campus loader, image manifest
|
||||
src/content/ copy, FAQs, kids info, consent text, testimonials
|
||||
src/components/ page sections (Hero, CampusFinder, PlanVisitForm, …)
|
||||
src/lib/ geo, attribution, analytics, schema, api client
|
||||
public/api/ PHP endpoints + lib (Mailchimp, Sinch, GeoIp, Geocode, LeadProcessor)
|
||||
public/api/cron/ retry-integrations.php, export-leads.php (CLI only)
|
||||
deploy/htaccess.root Apache config copied to out/.htaccess at build
|
||||
assets/source/ original logos/photos (see SOURCES.md)
|
||||
tests/ Vitest (TS) and tests/php (PHP)
|
||||
docs/ setup, deployment, launch checklist
|
||||
```
|
||||
|
||||
## Architecture decision (cPanel)
|
||||
|
||||
The target host is cPanel on Apache (confirmed: `waterschurch.org:2083` answers, server header `Apache`).
|
||||
Whether the account offers the Node.js Application Manager could not be verified without logging in,
|
||||
so the build targets the lowest common denominator that every cPanel account supports:
|
||||
|
||||
* **Frontend:** Next.js App Router + TypeScript + Tailwind, compiled with `output: "export"` to plain
|
||||
HTML/CSS/JS. No Node process runs in production.
|
||||
* **Backend:** PHP 8.1+ endpoints (cPanel ships PHP 8.x with cURL and PDO/SQLite). Secrets live in a
|
||||
`.env` file *outside* the document root. Leads are stored in SQLite (or cPanel MySQL) **before**
|
||||
Mailchimp/Sinch are called; failures are retried by a cPanel cron job.
|
||||
* **Location:** approximate server-side IP lookup (city-level, rounded, cached by hashed IP) for the
|
||||
recommendation; precise browser geolocation only on request; ZIP search always available.
|
||||
|
||||
If Waters Church later confirms Node hosting, the frontend needs no change; the PHP layer could be
|
||||
swapped for route handlers, but there is no functional reason to.
|
||||
|
||||
## Working in this Google Drive folder
|
||||
|
||||
This repo lives in a Google Drive shared drive. `node_modules/` is large and churns Drive sync;
|
||||
`.next/` and `out/` are symlinked to `~/.local/share/visit-waterschurch/` for that reason
|
||||
(`npm install` insists on a real `node_modules`). If you clone elsewhere, delete the two symlinks
|
||||
first: `rm .next out`.
|
||||
|
||||
## Facts and placeholders
|
||||
|
||||
Campus addresses, service times, phone numbers, kids age groups and safety practices were verified
|
||||
against waterschurch.org on 2026-09-22. Items that could **not** be verified are `null` in
|
||||
`config/campuses.json` (listed under each campus's `verify` array) or flagged `placeholder: true` /
|
||||
`confirm: true` in `src/content/`. Testimonials are intentionally empty. See
|
||||
[docs/LAUNCH-CHECKLIST.md](docs/LAUNCH-CHECKLIST.md).
|
||||
@@ -0,0 +1,612 @@
|
||||
You are a senior product designer, conversion-rate optimization strategist, and full-stack engineer. Build a production-ready advertising landing page for Waters Church that helps first-time visitors feel comfortable, understand what to expect, and take the next step toward attending a service.
|
||||
|
||||
## Primary objective
|
||||
|
||||
Create a modern, edgy, simple, mobile-first landing page that converts traffic from Facebook, Instagram, X, Google, and TikTok ads into prospective first-time visitors.
|
||||
|
||||
The page should:
|
||||
|
||||
1. Identify the Waters Church campus closest to the visitor.
|
||||
2. Give newcomers clear, reassuring information about their first visit.
|
||||
3. Capture contact information and visit intent.
|
||||
4. Add consenting visitors to a Mailchimp email journey.
|
||||
5. Send consenting visitors a confirmation text through Sinch Engage.
|
||||
6. Let visitors text the church with questions.
|
||||
7. Preserve advertising attribution for campaign reporting.
|
||||
|
||||
Do not create a generic church website. Create a focused, high-converting “Plan Your Visit” landing page with one primary conversion goal.
|
||||
|
||||
Add this section to the Claude Code prompt, immediately after “Primary objective”:
|
||||
|
||||
## Confirmed project information
|
||||
|
||||
Use these details throughout the implementation:
|
||||
|
||||
- Production URL: `https://visit.waterschurch.org`
|
||||
- Main Waters Church website: `https://waterschurch.org`
|
||||
- Hosting environment: cPanel
|
||||
- Official information, campus details, service times, brand references, logos, and approved photography can be sourced from `https://waterschurch.org`.
|
||||
|
||||
Treat the main Waters Church website as the primary source of truth for church information. Review it before designing or writing the landing page. Reuse accurate public information where appropriate, but do not copy large sections of text verbatim. Adapt the information into concise, newcomer-focused landing-page copy.
|
||||
|
||||
Download only the Waters Church logos and images needed for this project. Store optimized local copies in the project rather than hotlinking production assets. Preserve image quality, aspect ratio, and transparent backgrounds. Convert oversized photographs to modern web formats such as WebP or AVIF, while retaining fallbacks if needed. Do not alter the logo or invent new brand marks.
|
||||
|
||||
Verify campus addresses, coordinates, service times, contact details, kids information, and other factual claims against `waterschurch.org`. Make uncertain or missing information configurable and flag it for Waters Church to confirm before launch.
|
||||
|
||||
## cPanel hosting requirements
|
||||
|
||||
The finished project must be deployable through the church’s cPanel hosting environment.
|
||||
|
||||
Before selecting the final architecture:
|
||||
|
||||
1. Inspect the existing repository.
|
||||
2. Determine which runtimes the target cPanel account supports, including its Node.js version, application manager, PHP version, database options, scheduled tasks, SSL, environment-variable management, and reverse-proxy configuration.
|
||||
3. Do not assume that a continuously running Next.js server is supported.
|
||||
4. Choose the simplest secure architecture compatible with the available cPanel features.
|
||||
|
||||
Preferred deployment options, in order:
|
||||
|
||||
1. Use the existing application stack if it is already compatible with cPanel.
|
||||
2. Use a Node.js application through cPanel’s supported Node application manager if the account supports the required runtime and persistent server processes.
|
||||
3. If Node hosting is unavailable, produce an optimized static frontend with secure PHP server endpoints for form processing, lead persistence, Mailchimp, and Sinch Engage.
|
||||
4. Do not move secrets or third-party API calls into browser code to work around hosting limitations.
|
||||
|
||||
The deployed page must resolve at:
|
||||
|
||||
`https://visit.waterschurch.org`
|
||||
|
||||
Configure:
|
||||
|
||||
- The canonical URL to use `https://visit.waterschurch.org`.
|
||||
- Production metadata and social-sharing URLs for that domain.
|
||||
- HTTPS redirects.
|
||||
- Non-`www` URL consistency unless Waters Church specifies otherwise.
|
||||
- cPanel-compatible routing and rewrite rules.
|
||||
- Secure environment or configuration storage outside the public web root whenever possible.
|
||||
- Production error logging without exposing personal information or API credentials.
|
||||
- Appropriate file permissions.
|
||||
- A custom 404 response.
|
||||
- Cache headers and compression.
|
||||
- A deployment process that does not require manually editing generated build files.
|
||||
|
||||
If Apache configuration is required, provide the appropriate `.htaccess` file. If cPanel’s Node.js application manager is used, document the startup file, application root, environment variables, build command, startup command, and restart procedure.
|
||||
|
||||
Do not rely on serverless-platform-specific features such as Vercel functions, edge middleware, or proprietary geolocation headers unless an equivalent cPanel-compatible implementation is provided.
|
||||
|
||||
For approximate location detection, prefer a privacy-conscious server-side IP geolocation service that can operate from cPanel. Keep precise browser geolocation optional and initiated only by the visitor.
|
||||
|
||||
## Updated deployment deliverables
|
||||
|
||||
In addition to the other deliverables, provide:
|
||||
|
||||
- A production build compatible with the target cPanel account.
|
||||
- Any required `.htaccess`, Node startup, PHP endpoint, or database setup files.
|
||||
- A cPanel deployment guide written for a site administrator.
|
||||
- Instructions for creating and pointing the `visit.waterschurch.org` subdomain.
|
||||
- Instructions for enabling and verifying SSL.
|
||||
- Instructions for adding production environment variables securely.
|
||||
- Instructions for configuring scheduled retries or background processing using cPanel cron jobs, if needed.
|
||||
- Instructions for updating campus details, logos, photographs, service times, and contact numbers.
|
||||
- A post-deployment checklist covering forms, Mailchimp, Sinch Engage, location detection, analytics, consent records, redirects, SSL, and mobile performance.
|
||||
|
||||
Do not publish or change DNS until explicitly authorized. Prepare the project and deployment documentation so Waters Church can launch it safely through cPanel.
|
||||
|
||||
## First steps
|
||||
|
||||
Before writing code:
|
||||
|
||||
1. Inspect the existing repository, application structure, coding conventions, and design system.
|
||||
2. Reuse the existing framework and components when practical.
|
||||
3. Look for existing Waters Church branding, campus data, logos, photography, analytics, environment-variable conventions, and API integrations.
|
||||
4. If this is a greenfield project, use:
|
||||
- Next.js with the App Router
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
- Server-side route handlers for integrations
|
||||
- Zod or equivalent server-side validation
|
||||
5. Create a brief implementation plan, then build the complete experience.
|
||||
6. Do not stop at a mockup. Implement the functional page, form flow, campus selection, API integration layer, validation, tracking hooks, loading states, errors, and documentation.
|
||||
|
||||
Do not invent factual information about Waters Church. Put unknown campus details, service times, addresses, phone numbers, and API values into clearly documented configuration or structured placeholder data.
|
||||
|
||||
## Audience and message
|
||||
|
||||
The audience consists mainly of people who:
|
||||
|
||||
- Have never attended Waters Church.
|
||||
- May not regularly attend any church.
|
||||
- Are curious but uncertain about what the experience will be like.
|
||||
- May worry about dress, parking, children, service length, beliefs, or being pressured.
|
||||
- Are arriving from an advertisement and need an immediate connection between the ad and the page.
|
||||
|
||||
The experience should feel welcoming, confident, energetic, contemporary, and human. Avoid insider church language, clichés, guilt, pressure, and institutional wording.
|
||||
|
||||
Core message:
|
||||
|
||||
“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.”
|
||||
|
||||
## Visual direction
|
||||
|
||||
Create an original visual system that feels modern, bold, edgy, and uncluttered.
|
||||
|
||||
Use:
|
||||
|
||||
- Large, heavy display typography.
|
||||
- Short, direct headlines.
|
||||
- Strong contrast.
|
||||
- Generous spacing.
|
||||
- A restrained color palette aligned with existing Waters Church branding.
|
||||
- Bold photography or video areas, when appropriate.
|
||||
- Subtle motion and interaction feedback.
|
||||
- Clear, repeated calls to action.
|
||||
- Mobile layouts designed for one-handed use.
|
||||
- Accessible focus states and large tap targets.
|
||||
|
||||
Avoid:
|
||||
|
||||
- A generic church-template appearance.
|
||||
- Excessive gradients.
|
||||
- Overly rounded “startup SaaS” styling.
|
||||
- Busy navigation.
|
||||
- Long walls of text.
|
||||
- Stock religious imagery unless it genuinely fits the brand.
|
||||
- Carousels, autoplay audio, or distracting animation.
|
||||
|
||||
If brand assets are unavailable, use tasteful placeholders and organize them so they can be replaced without changing the layout.
|
||||
|
||||
## Page structure
|
||||
|
||||
### 1. Minimal header
|
||||
|
||||
Include the Waters Church logo and only essential actions:
|
||||
|
||||
- “Find Your Campus”
|
||||
- “Text Us”
|
||||
|
||||
Keep users focused on planning a visit. Do not add a full website navigation menu unless one already exists and is necessary.
|
||||
|
||||
### 2. Hero section
|
||||
|
||||
Create a high-impact hero with:
|
||||
|
||||
- A direct headline such as “Church Can Feel Different Here.”
|
||||
- Supporting copy that addresses first-visit uncertainty.
|
||||
- Primary CTA: “Plan My Visit”
|
||||
- Secondary CTA: “Find My Campus”
|
||||
- A visible “Questions? Text Us” action
|
||||
- Relevant Waters Church imagery or a well-designed media placeholder
|
||||
- A compact trust cue such as “No pressure. Just a friendly welcome.”
|
||||
|
||||
The primary CTA should scroll to or open the visit form without sending the user to another page.
|
||||
|
||||
### 3. Closest-campus experience
|
||||
|
||||
Offer the closest relevant Waters Church campus using location-aware logic.
|
||||
|
||||
Use this sequence:
|
||||
|
||||
1. On initial load, make a non-invasive approximate recommendation using server or edge location data if the hosting platform provides it.
|
||||
2. Clearly label the result as a recommendation.
|
||||
3. Offer “Use my precise location” as an optional action that triggers the browser permission request only after the user chooses it.
|
||||
4. Always provide a manual ZIP/postal-code search and campus selector.
|
||||
5. Never block the experience when location access is denied or unavailable.
|
||||
6. Save the selected campus through the conversion flow.
|
||||
7. Explain location use in plain language and do not retain precise coordinates unless truly necessary.
|
||||
|
||||
Calculate the closest campus from latitude and longitude when coordinates are available. Put all campus records in a typed configuration containing:
|
||||
|
||||
- Campus name
|
||||
- Address
|
||||
- Latitude and longitude
|
||||
- Service times
|
||||
- Campus-specific phone/SMS number
|
||||
- Directions URL
|
||||
- Campus page URL
|
||||
- Parking or arrival notes
|
||||
- Kids information
|
||||
- Optional campus image
|
||||
|
||||
The selected-campus card should include:
|
||||
|
||||
- Campus name
|
||||
- Distance when it can be calculated
|
||||
- Address
|
||||
- Upcoming service times
|
||||
- Directions action
|
||||
- “Choose This Campus”
|
||||
- “Text This Campus”
|
||||
- An easy way to select a different location
|
||||
|
||||
Do not silently claim a campus is closest if the location result is uncertain.
|
||||
|
||||
### 4. “What to Expect” section
|
||||
|
||||
Answer the most common newcomer questions in short, scannable content:
|
||||
|
||||
- What should I wear?
|
||||
- How long is a service?
|
||||
- What is the music and teaching like?
|
||||
- Where should I park?
|
||||
- What happens when I arrive?
|
||||
- Is there something for children?
|
||||
- Will I be singled out or pressured?
|
||||
- Is the venue accessible?
|
||||
|
||||
Use cards, expandable items, or a clean editorial layout. Keep the answers editable through configuration or content components.
|
||||
|
||||
### 5. Kids and family reassurance
|
||||
|
||||
Give families a concise explanation of:
|
||||
|
||||
- Kids check-in
|
||||
- Safety and security
|
||||
- Age groups
|
||||
- When to arrive
|
||||
- What parents should bring
|
||||
- Accessibility or special-needs support, if confirmed
|
||||
|
||||
Do not invent policies. Mark missing information as content placeholders.
|
||||
|
||||
### 6. Social proof
|
||||
|
||||
Add a restrained social-proof section using real testimonials only if they exist in the repository or supplied content. Do not fabricate quotations, attendance numbers, ratings, or claims.
|
||||
|
||||
If real content is unavailable, create an obvious content placeholder that can be removed cleanly before launch.
|
||||
|
||||
### 7. Plan Your Visit form
|
||||
|
||||
Build a short, conversion-oriented multi-step or progressive form. Ask only for information that has a clear purpose.
|
||||
|
||||
Recommended fields:
|
||||
|
||||
- First name — required
|
||||
- Last name — optional
|
||||
- Email — required
|
||||
- Mobile phone — required only if the visitor elects to receive texts
|
||||
- Selected campus — required
|
||||
- Expected visit date or service — optional
|
||||
- Number of adults — optional
|
||||
- Number and age ranges of children — optional
|
||||
- “What questions can we answer?” — optional
|
||||
- Email marketing consent
|
||||
- SMS consent with a separate, unchecked checkbox
|
||||
|
||||
Do not bundle SMS and email consent. Consent boxes must not be preselected.
|
||||
|
||||
Place concise privacy and consent language beside the relevant controls. The SMS disclosure should explain that the visitor agrees to receive messages from Waters Church, that message and data rates may apply, that frequency may vary, and how to opt out. Link to Privacy Policy and Terms pages or configurable URLs.
|
||||
|
||||
Use accessible labels, inline validation, server-side validation, bot protection, rate limiting, and a honeypot or equivalent anti-spam control.
|
||||
|
||||
The main button should use intent-based language such as:
|
||||
|
||||
- “Plan My Visit”
|
||||
- “Help Me Get Ready”
|
||||
- “Send My Visit Details”
|
||||
|
||||
Avoid “Submit.”
|
||||
|
||||
### 8. Confirmation experience
|
||||
|
||||
After a successful submission:
|
||||
|
||||
- Show a warm confirmation message.
|
||||
- Confirm the selected campus and visit details.
|
||||
- Display directions and service times.
|
||||
- Explain that an email is on the way.
|
||||
- If SMS consent was provided, explain that a text is on the way.
|
||||
- Provide a prominent “Text Us a Question” action.
|
||||
- Preserve the page design instead of displaying a generic success alert.
|
||||
- Fire a conversion event only after the server confirms success.
|
||||
|
||||
If one integration succeeds and another fails, do not lose the lead. Store or log a recoverable integration state, show the user a sensible confirmation, and record the failure for retry or follow-up without exposing technical details.
|
||||
|
||||
## “Text Us” functionality
|
||||
|
||||
Provide a persistent but unobtrusive “Text Us” action throughout the experience.
|
||||
|
||||
On supported devices, use an `sms:` link associated with the selected campus. If no campus has been selected, use a configurable central Waters Church SMS number or ask the visitor to choose a campus.
|
||||
|
||||
Pre-fill a natural message such as:
|
||||
|
||||
“Hi! I’m thinking about visiting Waters Church and have a question.”
|
||||
|
||||
Make sure the visitor can edit the message before sending it.
|
||||
|
||||
The automated confirmation text sent after form submission should be concise and conversational. Example structure:
|
||||
|
||||
“Hi {{first_name}} — we’re excited that you’re considering Waters Church {{campus_name}}. Your visit details: {{service_or_date}}. Have a question? Reply to this message. Reply STOP to opt out.”
|
||||
|
||||
Treat this only as editable copy. Ensure the final implementation follows the actual Sinch Engage capabilities, local regulations, and approved Waters Church messaging policies.
|
||||
|
||||
## Sinch Engage integration
|
||||
|
||||
Implement the SMS integration behind a server-only adapter.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Never expose credentials in client-side code.
|
||||
- Use environment variables.
|
||||
- Validate and normalize phone numbers, preferably to E.164 format.
|
||||
- Send an automated message only when explicit SMS consent was provided.
|
||||
- Store consent timestamp, disclosure version, source, selected campus, and attribution data.
|
||||
- Include appropriate opt-out handling in the message and downstream workflow.
|
||||
- Use idempotency protection so retries do not send duplicate texts.
|
||||
- Handle API errors and timeouts safely.
|
||||
- Redact personal information and secrets from logs.
|
||||
- Add a mock/development mode so the flow can be tested without sending a real text.
|
||||
- Document which Sinch Engage endpoint, sender identity, template, and credentials must be configured.
|
||||
|
||||
Sinch products and account configurations can differ. Use the official documentation matching the project’s actual Sinch Engage account. Isolate provider-specific code so it can be adjusted without changing the form or page.
|
||||
|
||||
Create an adapter interface similar to:
|
||||
|
||||
- `sendVisitConfirmation()`
|
||||
- `sendInternalLeadNotification()`, only if supported and desired
|
||||
- `normalizePhoneNumber()`
|
||||
|
||||
Do not pretend that SMS delivery succeeded if the provider did not confirm acceptance.
|
||||
|
||||
## Mailchimp integration
|
||||
|
||||
Implement the Mailchimp connection behind a server-only adapter.
|
||||
|
||||
On valid form submission:
|
||||
|
||||
- Create or update the contact in the correct audience.
|
||||
- Avoid creating duplicate contacts.
|
||||
- Apply tags for:
|
||||
- First-time visitor
|
||||
- Selected campus
|
||||
- Landing-page source
|
||||
- Advertising platform
|
||||
- Campaign
|
||||
- Visit date or service, when supplied
|
||||
- Map useful merge fields.
|
||||
- Trigger or qualify the contact for the appropriate welcome/first-visit customer journey.
|
||||
- Respect the email consent state and the audience’s opt-in model.
|
||||
- Preserve the lead safely if Mailchimp is temporarily unavailable.
|
||||
- Add idempotency protection.
|
||||
- Provide a mock/development mode.
|
||||
- Never expose the Mailchimp API key in the browser.
|
||||
|
||||
Document the required Mailchimp environment variables, audience ID, server prefix, tags, merge fields, and automation prerequisites.
|
||||
|
||||
## Lead storage and reliability
|
||||
|
||||
Do not make Mailchimp or Sinch the only record of the form submission.
|
||||
|
||||
If the existing application has a database, save the lead and integration status there before calling external services. If it does not, implement a clean persistence interface and a development adapter, then clearly document the production storage option that must be connected.
|
||||
|
||||
Track:
|
||||
|
||||
- Submission ID
|
||||
- Timestamp
|
||||
- First and last name
|
||||
- Email and phone
|
||||
- Consent choices and consent-language version
|
||||
- Selected campus
|
||||
- Visit details
|
||||
- Questions
|
||||
- UTM and click identifiers
|
||||
- Landing-page variant
|
||||
- Referrer
|
||||
- Mailchimp status
|
||||
- Sinch status
|
||||
- Retry state
|
||||
|
||||
Protect personal data, minimize what is retained, and define a deletion or retention approach in the documentation.
|
||||
|
||||
## Advertising attribution and analytics
|
||||
|
||||
Capture and preserve the full attribution context across the form experience, including when the user changes campus or moves between steps.
|
||||
|
||||
Support:
|
||||
|
||||
- `utm_source`
|
||||
- `utm_medium`
|
||||
- `utm_campaign`
|
||||
- `utm_content`
|
||||
- `utm_term`
|
||||
- `gclid`
|
||||
- `gbraid`
|
||||
- `wbraid`
|
||||
- `fbclid`
|
||||
- TikTok click ID when present
|
||||
- X/Twitter click identifiers when present
|
||||
- Landing-page variant
|
||||
- Initial referrer
|
||||
- First landing URL
|
||||
|
||||
Create a consent-aware analytics layer with clearly named events such as:
|
||||
|
||||
- `landing_page_view`
|
||||
- `campus_recommended`
|
||||
- `location_permission_requested`
|
||||
- `campus_selected`
|
||||
- `plan_visit_started`
|
||||
- `plan_visit_step_completed`
|
||||
- `plan_visit_submitted`
|
||||
- `plan_visit_success`
|
||||
- `text_us_clicked`
|
||||
- `directions_clicked`
|
||||
|
||||
Include event properties such as campus, source, campaign, device category, and landing-page variant, but do not send unnecessary personal information to ad platforms or analytics tools.
|
||||
|
||||
Provide integration hooks for the organization’s chosen analytics and advertising tools. Do not hardcode production tracking IDs.
|
||||
|
||||
## SEO and landing-page marketing
|
||||
|
||||
Although this is an advertising destination, make it technically strong for search and sharing.
|
||||
|
||||
Implement:
|
||||
|
||||
- A focused title and meta description.
|
||||
- Canonical URL support.
|
||||
- Open Graph and social-sharing metadata.
|
||||
- Appropriate robots behavior controlled by environment.
|
||||
- Organization/Church and location schema using verified information only.
|
||||
- LocalBusiness or Place schema for campuses when appropriate.
|
||||
- Semantic heading structure.
|
||||
- Descriptive link labels and image alt text.
|
||||
- Fast loading and minimal client-side JavaScript.
|
||||
- Optimized responsive images.
|
||||
- Sitemap integration if the project uses a sitemap.
|
||||
- Clean, readable URL structure.
|
||||
- Campus-specific copy and metadata where the architecture supports it.
|
||||
|
||||
Do not keyword-stuff. Write for newcomers first. Keep the primary CTA visible and ensure message continuity between ad copy and landing-page content.
|
||||
|
||||
## Performance and accessibility
|
||||
|
||||
Target excellent mobile performance and Core Web Vitals.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Mobile-first responsive design.
|
||||
- WCAG 2.2 AA-oriented implementation.
|
||||
- Full keyboard navigation.
|
||||
- Visible focus styles.
|
||||
- Proper form labels and error associations.
|
||||
- Sufficient color contrast.
|
||||
- Reduced-motion support.
|
||||
- No layout shifts from images or fonts.
|
||||
- Optimized font loading.
|
||||
- Lazy-load content that is below the fold.
|
||||
- Avoid unnecessary third-party scripts.
|
||||
- Test at common mobile widths and slow-network conditions.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
Implement:
|
||||
|
||||
- Server-side validation and sanitization.
|
||||
- Rate limiting.
|
||||
- Bot protection that does not create needless friction.
|
||||
- Same-origin and CSRF protections where appropriate.
|
||||
- Secret management through environment variables.
|
||||
- Safe error messages.
|
||||
- Redacted logs.
|
||||
- Request timeouts.
|
||||
- Idempotent form processing.
|
||||
- A configurable consent-disclosure version.
|
||||
- Privacy Policy and Terms links.
|
||||
- No automatic precise-location request on page load.
|
||||
- No email enrollment or SMS delivery without the applicable consent.
|
||||
|
||||
Do not make legal-compliance claims. Flag all consent language, data retention, TCPA-related SMS behavior, and privacy disclosures for review by Waters Church’s legal or compliance adviser before launch.
|
||||
|
||||
## Content and configuration architecture
|
||||
|
||||
Keep frequently changing information outside presentation components.
|
||||
|
||||
Create typed configuration for:
|
||||
|
||||
- Church name and central contact details
|
||||
- Campuses
|
||||
- Addresses and coordinates
|
||||
- Service times
|
||||
- SMS numbers
|
||||
- Directions
|
||||
- Kids information
|
||||
- FAQs
|
||||
- Testimonials
|
||||
- Privacy and Terms URLs
|
||||
- Consent copy and version
|
||||
- Analytics IDs
|
||||
- Mailchimp mapping
|
||||
- Sinch sender and templates
|
||||
|
||||
If a CMS already exists, use it appropriately. Otherwise, make the configuration simple for a non-developer to update.
|
||||
|
||||
## Testing
|
||||
|
||||
Add meaningful tests for:
|
||||
|
||||
- Campus distance calculation
|
||||
- Postal-code/manual-location fallback
|
||||
- Form validation
|
||||
- Consent enforcement
|
||||
- Attribution capture
|
||||
- Mailchimp field and tag mapping
|
||||
- Sinch message construction
|
||||
- Idempotency behavior
|
||||
- Partial integration failure
|
||||
- Successful submission
|
||||
- Accessible keyboard operation for the main flow
|
||||
|
||||
Also manually verify:
|
||||
|
||||
- Small and large mobile screens
|
||||
- Desktop layout
|
||||
- Denied location permission
|
||||
- Unsupported geolocation
|
||||
- No nearby campus result
|
||||
- Missing or malformed campaign parameters
|
||||
- API timeout and provider failure
|
||||
- Duplicate form submission
|
||||
- Screen-reader-friendly errors
|
||||
- Text links on iOS and Android where possible
|
||||
|
||||
## Environment variables
|
||||
|
||||
Add a documented `.env.example` containing placeholders only. Include the variables required for:
|
||||
|
||||
- Public site URL
|
||||
- Database or lead-storage connection
|
||||
- Mailchimp API key
|
||||
- Mailchimp server prefix
|
||||
- Mailchimp audience ID
|
||||
- Mailchimp journey/tag configuration
|
||||
- Sinch Engage API base URL
|
||||
- Sinch project/service identifiers
|
||||
- Sinch API credentials
|
||||
- Sinch sender identity
|
||||
- Central SMS number
|
||||
- Geocoding provider, if one is required
|
||||
- Analytics and advertising IDs
|
||||
- Bot-protection service
|
||||
- Preview/mock mode
|
||||
|
||||
Never place real secrets in the repository.
|
||||
|
||||
## Deliverables
|
||||
|
||||
Complete the work with:
|
||||
|
||||
1. The functional landing page.
|
||||
2. Responsive styling and interactions.
|
||||
3. Campus recommendation and manual selection.
|
||||
4. The visit-planning form and confirmation state.
|
||||
5. Mailchimp and Sinch server-side adapter layers.
|
||||
6. Attribution and analytics hooks.
|
||||
7. Validation, consent, reliability, and anti-spam protections.
|
||||
8. Tests.
|
||||
9. `.env.example`.
|
||||
10. A concise setup guide explaining:
|
||||
- How to enter campus details
|
||||
- How to connect Mailchimp
|
||||
- How to connect Sinch Engage
|
||||
- How to configure tracking
|
||||
- How to run locally
|
||||
- How to test without sending messages
|
||||
- How to deploy
|
||||
- Which content and compliance items require human approval
|
||||
|
||||
## Definition of done
|
||||
|
||||
The project is finished when:
|
||||
|
||||
- A visitor arriving from an ad immediately understands the purpose of the page.
|
||||
- The page suggests a campus without forcing location permission.
|
||||
- The visitor can always choose a campus manually.
|
||||
- The visitor can confidently understand what a first visit will be like.
|
||||
- The form works on mobile and has clear, separate consent choices.
|
||||
- Successful submissions are persisted before external integrations run.
|
||||
- Consenting contacts are correctly prepared for Mailchimp.
|
||||
- Consenting phone numbers receive one idempotent Sinch confirmation request.
|
||||
- Visitors can initiate a text conversation with the appropriate church number.
|
||||
- Campaign attribution reaches the stored lead and integration mapping.
|
||||
- Failure states are graceful and recoverable.
|
||||
- The experience is accessible, fast, secure, and visually polished.
|
||||
- No factual church information, testimonials, legal language, or API success has been fabricated.
|
||||
- The setup documentation allows another developer to configure and deploy the site.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Brand asset sources
|
||||
|
||||
All files were downloaded from the public Waters Church website on 2026-09-22
|
||||
and are used with the assumption that Waters Church owns the rights.
|
||||
Do not alter the logo marks. Replace photos freely; keep the same file names
|
||||
so the build picks them up, then run `npm run images`.
|
||||
|
||||
| File | Source URL |
|
||||
| --- | --- |
|
||||
| logos/waters-mark-teal.png | https://waterschurch.org/wp-content/uploads/2018/03/NewWlogo_3000.png |
|
||||
| logos/waters-mark-white.svg | https://waterschurch.org/wp-content/uploads/2026/01/wlogo-white.svg |
|
||||
| logos/waters-church-wordmark-white.svg | https://waterschurch.org/wp-content/uploads/2020/12/WATERS-CHURCH-Text.svg |
|
||||
| photos/hero-north-attleboro-worship.jpg | https://waterschurch.org/wp-content/uploads/2024/02/2402-North-Attleboro-Stage-Shot-compressed.jpeg (site og:image) |
|
||||
| photos/campus-north-attleboro.jpg | https://waterschurch.org/wp-content/uploads/2021/05/nacard.jpg |
|
||||
| photos/campus-norwood.jpg | https://waterschurch.org/wp-content/uploads/2021/05/nwcard.jpg |
|
||||
| photos/campus-woonsocket.jpg | https://waterschurch.org/wp-content/uploads/2021/05/wscard.jpg |
|
||||
| photos/campus-southcoast.jpg | https://waterschurch.org/wp-content/uploads/2022/12/southcoast-header.jpg |
|
||||
| photos/campus-apollo-beach.jpg | https://waterschurch.org/wp-content/uploads/2021/05/ApolloBeachSign.jpg |
|
||||
| photos/campus-orlando.jpg | https://waterschurch.org/wp-content/uploads/2024/11/orlando-header.jpg |
|
||||
| photos/worship-southcoast.jpg | https://waterschurch.org/wp-content/uploads/2025/06/3F7A0432.jpeg |
|
||||
| photos/community-1.jpg | https://waterschurch.org/wp-content/uploads/2022/03/community1.jpg |
|
||||
| photos/community-woonsocket.jpg | https://waterschurch.org/wp-content/uploads/2022/04/woonsocket-community2.jpg |
|
||||
| photos/save-a-seat.jpg | https://waterschurch.org/wp-content/uploads/2022/03/saveaseat_editted.jpg |
|
||||
| photos/gathering-2023.jpg | https://waterschurch.org/wp-content/uploads/2024/02/20230730-Img_9957-1.jpg |
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1281.03" height="102.21" viewBox="0 0 1281.03 102.21"><path d="M0,1.69H23.29L43.76,68.61,65.93,1.41H84.56l22.17,67.2L127.2,1.69h22.73l-33.74,99.53H97.27L75,36.56l-22.3,64.66H33.74Z" fill="#fff"/><path d="M187.2,1h20.05l42.35,99.53H226.87l-9-22.17H176.05l-9,22.17H144.85Zm22.87,58.16-13.13-32-13.13,32Z" fill="#fff"/><path d="M275.15,21.74H245.08v-20H327v20H296.89v78.78H275.15Z" fill="#fff"/><path d="M343.62,1.69h74.54V21H365.22v20h46.59V60.42H365.22V81.18h53.65v19.34H343.62Z" fill="#fff"/><path d="M438.21,1.69h45.18c12.56,0,22.3,3.53,28.8,10,5.5,5.5,8.47,13.27,8.47,22.59v.28c0,15.95-8.61,26-21.18,30.63l24.14,35.3H498.21L477,68.89H460v31.63H438.21Zm43.77,48c10.59,0,16.66-5.64,16.66-14v-.29c0-9.31-6.5-14.11-17.09-14.11H460V49.69Z" fill="#fff"/><path d="M533.51,86.12l12.84-15.39c8.9,7.34,18.22,12,29.51,12,8.89,0,14.26-3.53,14.26-9.32v-.28c0-5.51-3.39-8.33-19.91-12.57-19.9-5.08-32.75-10.58-32.75-30.21v-.28c0-17.93,14.4-29.79,34.59-29.79,14.4,0,26.68,4.52,36.7,12.57L597.46,29.22c-8.75-6.07-17.37-9.74-25.69-9.74s-12.71,3.81-12.71,8.61v.29c0,6.49,4.23,8.61,21.32,13,20,5.23,31.34,12.43,31.34,29.65v.28c0,19.63-15,30.64-36.28,30.64A62.92,62.92,0,0,1,533.51,86.12Z" fill="#fff"/><path d="M668.61,51.39V51.1C668.61,23,689.79,0,720.14,0c18.64,0,29.79,6.21,39,15.25l-13.83,16c-7.62-6.92-15.39-11.15-25.27-11.15-16.66,0-28.66,13.83-28.66,30.77v.28c0,16.95,11.72,31.06,28.66,31.06,11.29,0,18.21-4.51,26-11.57l13.83,14c-10.16,10.87-21.46,17.65-40.52,17.65C690.21,102.21,668.61,79.76,668.61,51.39Z" fill="#fff"/><path d="M777.18,1.69h21.74V40.8H839V1.69h21.74v98.83H839V60.85H798.92v39.67H777.18Z" fill="#fff"/><path d="M882.92,58.16V1.69h21.74V57.6C904.66,73.69,912.71,82,926,82s21.31-8,21.31-23.71V1.69H969V57.46c0,29.93-16.8,44.61-43.34,44.61S882.92,87.25,882.92,58.16Z" fill="#fff"/><path d="M991.2,1.69h45.18c12.56,0,22.3,3.53,28.79,10,5.51,5.5,8.48,13.27,8.48,22.59v.28c0,15.95-8.62,26-21.18,30.63l24.14,35.3H1051.2L1030,68.89h-17.08v31.63H991.2Zm43.76,48c10.59,0,16.66-5.64,16.66-14v-.29c0-9.31-6.49-14.11-17.08-14.11h-21.6V49.69Z" fill="#fff"/><path d="M1088.89,51.39V51.1c0-28.09,21.18-51.1,51.53-51.1,18.64,0,29.79,6.21,39,15.25l-13.84,16c-7.62-6.92-15.39-11.15-25.27-11.15-16.66,0-28.66,13.83-28.66,30.77v.28c0,16.95,11.72,31.06,28.66,31.06,11.3,0,18.21-4.51,26-11.57l13.83,14c-10.16,10.87-21.46,17.65-40.51,17.65C1110.49,102.21,1088.89,79.76,1088.89,51.39Z" fill="#fff"/><path d="M1197.46,1.69h21.74V40.8h40.09V1.69H1281v98.83h-21.74V60.85H1219.2v39.67h-21.74Z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 800 KiB |
@@ -0,0 +1 @@
|
||||
<svg id="26f08cf5-6671-47ce-be52-85c85dc315fd" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2711.06 2702.83"><title>wlogo-white</title><path d="M1500.41,148.38C753,148.38,144.9,754.61,144.9,1499.8S753,2851.21,1500.41,2851.21,2856,2245,2856,1499.8,2247.87,148.38,1500.41,148.38Zm0,2437c-600.4,0-1088.87-487-1088.87-1085.56S900,414.24,1500.41,414.24c255.2,0,490.15,88,676,235.15a160,160,0,0,0-7.65,17.13l-258.28,778v0l-.88,2.74c-4,11.63-22.28,55.83-69.45,55.83-54.34,0-72.29-58.58-72.29-58.58v0L1655.6,1101.72a151.6,151.6,0,0,0-144.17-104.25h-.65A151.55,151.55,0,0,0,1366.85,1103L1254.2,1457.39c-8.17,17.22-27.4,45.72-65.29,45.72-40.11,0-60.38-31.87-68.23-48.59L989.9,1101.72c-26-79.35-111.61-122.69-191.24-96.78s-123.09,111.27-97.13,190.64l345.34,846.13A151.6,151.6,0,0,0,1191,2146h.67a151.6,151.6,0,0,0,143.93-105.54l105.89-333.22s16.77-55,71.07-55,72.29,58.56,72.29,58.56l112.45,330.88A151.53,151.53,0,0,0,1841.51,2146h.46a151.66,151.66,0,0,0,144-105.18L2392.6,878.2c123.88,176.21,196.7,390.56,196.7,621.59C2589.31,2098.39,2100.83,2585.36,1500.41,2585.36Z" transform="translate(-144.9 -148.38)" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 799 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 708 KiB |
|
After Width: | Height: | Size: 1011 KiB |
|
After Width: | Height: | Size: 408 KiB |
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"$schema": "./campuses.schema.json",
|
||||
"_readme": "Single source of truth for campus data. Read by the Next.js frontend (src/config/campuses.ts) and by the PHP API (public/api/lib/campuses.php). Facts were verified against waterschurch.org on 2026-09-22 unless listed under `verify`. Edit this file, then rebuild (npm run build) and re-upload.",
|
||||
"campuses": [
|
||||
{
|
||||
"id": "north-attleboro",
|
||||
"name": "North Attleboro",
|
||||
"label": "North Attleboro, MA",
|
||||
"status": "open",
|
||||
"isCentral": true,
|
||||
"address": {
|
||||
"line1": "57 John L Dietsch Square",
|
||||
"city": "North Attleboro",
|
||||
"state": "MA",
|
||||
"postalCode": "02763"
|
||||
},
|
||||
"coordinates": { "lat": 41.97396, "lng": -71.29631 },
|
||||
"timezone": "America/New_York",
|
||||
"serviceTimes": [
|
||||
{ "day": "Sunday", "times": ["7:30 AM", "9:30 AM", "11:30 AM"] }
|
||||
],
|
||||
"phone": "+15086951300",
|
||||
"smsNumber": "+15086951300",
|
||||
"sinchAccountId": "WatersChurch_OLA_0007",
|
||||
"email": "info@waterschurch.org",
|
||||
"campusUrl": "https://waterschurch.org/locations/north-attleboro/",
|
||||
"directionsUrl": "https://www.google.com/maps/dir/?api=1&destination=Waters+Church%2C+57+John+L+Dietsch+Square%2C+North+Attleboro%2C+MA+02763",
|
||||
"leader": { "title": "Lead Pastor", "name": "Tim Hatch" },
|
||||
"arrivalNotes": null,
|
||||
"parkingNotes": null,
|
||||
"kidsNotes": "Waters Kids programs run at every service time.",
|
||||
"image": "north-attleboro",
|
||||
"verify": ["parkingNotes", "arrivalNotes", "coordinates"]
|
||||
},
|
||||
{
|
||||
"id": "norwood",
|
||||
"name": "Norwood",
|
||||
"label": "Norwood, MA",
|
||||
"status": "open",
|
||||
"isCentral": false,
|
||||
"address": {
|
||||
"line1": "668 Walpole Street",
|
||||
"city": "Norwood",
|
||||
"state": "MA",
|
||||
"postalCode": "02062"
|
||||
},
|
||||
"coordinates": { "lat": 42.17153, "lng": -71.22482 },
|
||||
"timezone": "America/New_York",
|
||||
"serviceTimes": [
|
||||
{ "day": "Sunday", "times": ["9:30 AM", "11:00 AM"] },
|
||||
{ "day": "First Wednesday of the month", "times": ["7:00 PM"] }
|
||||
],
|
||||
"phone": "+17817623357",
|
||||
"smsNumber": "+17817623357",
|
||||
"sinchAccountId": "WatersChurch_OLA_0002",
|
||||
"email": "info@waterschurchnorwood.org",
|
||||
"campusUrl": "https://waterschurch.org/locations/norwood/",
|
||||
"directionsUrl": "https://www.google.com/maps/dir/?api=1&destination=Waters+Church+Norwood%2C+668+Walpole+Street%2C+Norwood%2C+MA+02062",
|
||||
"leader": { "title": "Senior Pastor", "name": "Tommy Geyser" },
|
||||
"arrivalNotes": null,
|
||||
"parkingNotes": null,
|
||||
"kidsNotes": "Waters Kids programs run at every service time.",
|
||||
"image": "norwood",
|
||||
"verify": ["parkingNotes", "arrivalNotes", "coordinates"]
|
||||
},
|
||||
{
|
||||
"id": "woonsocket",
|
||||
"name": "Woonsocket",
|
||||
"label": "Woonsocket, RI",
|
||||
"status": "open",
|
||||
"isCentral": false,
|
||||
"address": {
|
||||
"line1": "1500 Diamond Hill Road",
|
||||
"city": "Woonsocket",
|
||||
"state": "RI",
|
||||
"postalCode": "02895"
|
||||
},
|
||||
"coordinates": { "lat": 42.01634, "lng": -71.47692 },
|
||||
"timezone": "America/New_York",
|
||||
"serviceTimes": [
|
||||
{ "day": "Sunday", "times": ["9:00 AM", "11:00 AM"] }
|
||||
],
|
||||
"phone": "+15086951300",
|
||||
"smsNumber": "+18332414837",
|
||||
"sinchAccountId": "WatersChurch_OLA_0005",
|
||||
"email": "info@waterschurch.org",
|
||||
"campusUrl": "https://waterschurch.org/locations/woonsocket/",
|
||||
"directionsUrl": "https://www.google.com/maps/dir/?api=1&destination=Waters+Church+Woonsocket%2C+1500+Diamond+Hill+Rd%2C+Woonsocket%2C+RI+02895",
|
||||
"leader": { "title": "Location Pastor", "name": "Jim Shekleton" },
|
||||
"arrivalNotes": null,
|
||||
"parkingNotes": null,
|
||||
"kidsNotes": "Waters Kids programs run at every service time.",
|
||||
"image": "woonsocket",
|
||||
"verify": ["parkingNotes", "arrivalNotes", "coordinates", "phone"]
|
||||
},
|
||||
{
|
||||
"id": "southcoast",
|
||||
"name": "Southcoast",
|
||||
"label": "Southcoast, RI/MA",
|
||||
"status": "open",
|
||||
"isCentral": false,
|
||||
"address": {
|
||||
"line1": "28 Fish Road",
|
||||
"city": "Tiverton",
|
||||
"state": "RI",
|
||||
"postalCode": "02878"
|
||||
},
|
||||
"coordinates": { "lat": 41.66752, "lng": -71.17603 },
|
||||
"timezone": "America/New_York",
|
||||
"serviceTimes": [
|
||||
{ "day": "Sunday", "times": ["10:30 AM"] }
|
||||
],
|
||||
"phone": "+15086951300",
|
||||
"smsNumber": "+15088093490",
|
||||
"sinchAccountId": "WatersChurch_OLA_0006",
|
||||
"email": "info@waterschurch.org",
|
||||
"campusUrl": "https://waterschurch.org/locations/southcoast/",
|
||||
"directionsUrl": "https://www.google.com/maps/dir/?api=1&destination=Waters+Church+Southcoast%2C+28+Fish+Road%2C+Tiverton%2C+RI+02878",
|
||||
"leader": { "title": "Location Pastor", "name": "Mehdi Chniouli" },
|
||||
"arrivalNotes": null,
|
||||
"parkingNotes": null,
|
||||
"kidsNotes": "Waters Kids programs run at every service time.",
|
||||
"image": "southcoast",
|
||||
"verify": ["parkingNotes", "arrivalNotes", "coordinates", "phone"]
|
||||
},
|
||||
{
|
||||
"id": "apollo-beach",
|
||||
"name": "Apollo Beach",
|
||||
"label": "Apollo Beach, FL",
|
||||
"status": "open",
|
||||
"isCentral": false,
|
||||
"address": {
|
||||
"line1": "5309 North US Hwy 41",
|
||||
"city": "Apollo Beach",
|
||||
"state": "FL",
|
||||
"postalCode": "33572"
|
||||
},
|
||||
"coordinates": { "lat": 27.74286, "lng": -82.41918 },
|
||||
"timezone": "America/New_York",
|
||||
"serviceTimes": [
|
||||
{ "day": "Sunday", "times": ["9:00 AM", "11:00 AM"] }
|
||||
],
|
||||
"phone": "+18136426979",
|
||||
"smsNumber": "+18136426979",
|
||||
"sinchAccountId": "WatersChurch_OLA_0003",
|
||||
"email": "info@waterschurch.org",
|
||||
"campusUrl": "https://waterschurch.org/locations/apollo-beach/",
|
||||
"directionsUrl": "https://www.google.com/maps/dir/?api=1&destination=Waters+Church+Apollo+Beach%2C+5309+N+US+Hwy+41%2C+Apollo+Beach%2C+FL+33572",
|
||||
"leader": { "title": "Location Pastor", "name": "Tom Duck" },
|
||||
"arrivalNotes": null,
|
||||
"parkingNotes": null,
|
||||
"kidsNotes": "Waters Kids programs run at every service time.",
|
||||
"image": "apollo-beach",
|
||||
"verify": ["parkingNotes", "arrivalNotes", "coordinates"]
|
||||
},
|
||||
{
|
||||
"id": "orlando",
|
||||
"name": "Orlando",
|
||||
"label": "Orlando, FL",
|
||||
"status": "launching",
|
||||
"isCentral": false,
|
||||
"address": {
|
||||
"line1": "Castle Door Studios, 1890 Kentucky Ave",
|
||||
"city": "Winter Park",
|
||||
"state": "FL",
|
||||
"postalCode": "32789"
|
||||
},
|
||||
"coordinates": { "lat": 28.59213, "lng": -81.37501 },
|
||||
"timezone": "America/New_York",
|
||||
"serviceTimes": [
|
||||
{ "day": "Sunday", "times": ["10:30 AM"], "note": "Doors open 10:00 AM. Currently gathering with the launch team only." }
|
||||
],
|
||||
"phone": "+15086951300",
|
||||
"smsNumber": null,
|
||||
"sinchAccountId": null,
|
||||
"email": "info@waterschurch.org",
|
||||
"campusUrl": "https://waterschurch.org/locations/orlando/",
|
||||
"directionsUrl": "https://www.google.com/maps/dir/?api=1&destination=1890+Kentucky+Ave%2C+Winter+Park%2C+FL+32789",
|
||||
"leader": { "title": "Pastor", "name": "Tim Hatch" },
|
||||
"arrivalNotes": null,
|
||||
"parkingNotes": null,
|
||||
"kidsNotes": null,
|
||||
"image": "orlando",
|
||||
"verify": ["status", "serviceTimes", "smsNumber", "kidsNotes", "coordinates"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Waters Church campuses",
|
||||
"type": "object",
|
||||
"required": ["campuses"],
|
||||
"properties": {
|
||||
"campuses": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "name", "label", "status", "address", "coordinates", "serviceTimes", "phone", "campusUrl", "directionsUrl"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "pattern": "^[a-z0-9-]+$" },
|
||||
"name": { "type": "string" },
|
||||
"label": { "type": "string" },
|
||||
"status": { "enum": ["open", "launching", "hidden"] },
|
||||
"isCentral": { "type": "boolean" },
|
||||
"address": {
|
||||
"type": "object",
|
||||
"required": ["line1", "city", "state", "postalCode"],
|
||||
"properties": {
|
||||
"line1": { "type": "string" }, "city": { "type": "string" },
|
||||
"state": { "type": "string" }, "postalCode": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"coordinates": { "type": "object", "required": ["lat", "lng"], "properties": { "lat": { "type": "number" }, "lng": { "type": "number" } } },
|
||||
"timezone": { "type": "string" },
|
||||
"serviceTimes": {
|
||||
"type": "array",
|
||||
"items": { "type": "object", "required": ["day", "times"], "properties": { "day": { "type": "string" }, "times": { "type": "array", "items": { "type": "string" } }, "note": { "type": ["string", "null"] } } }
|
||||
},
|
||||
"phone": { "type": "string", "pattern": "^\\+[1-9][0-9]{6,14}$" },
|
||||
"smsNumber": { "type": ["string", "null"], "pattern": "^\\+[1-9][0-9]{6,14}$" },
|
||||
"sinchAccountId": { "type": ["string", "null"] },
|
||||
"email": { "type": ["string", "null"] },
|
||||
"campusUrl": { "type": "string", "format": "uri" },
|
||||
"directionsUrl": { "type": "string", "format": "uri" },
|
||||
"leader": { "type": ["object", "null"], "properties": { "title": { "type": "string" }, "name": { "type": "string" } } },
|
||||
"arrivalNotes": { "type": ["string", "null"] },
|
||||
"parkingNotes": { "type": ["string", "null"] },
|
||||
"kidsNotes": { "type": ["string", "null"] },
|
||||
"image": { "type": ["string", "null"] },
|
||||
"verify": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Deploy visit.waterschurch.org to the cPanel host over SSH.
|
||||
#
|
||||
# deploy/deploy.sh # build + upload + configure (goes live)
|
||||
# deploy/deploy.sh --no-build # upload the existing ./out
|
||||
# deploy/deploy.sh --keep-redirect # deploy but keep sending visitors to
|
||||
# # the Church Center form (pre-launch)
|
||||
#
|
||||
# Requires: the SSH alias "waterschurch" from ~/.ssh/config, rsync, node, npm.
|
||||
# Idempotent: safe to re-run for every content update.
|
||||
#
|
||||
# On the server (as the cPanel user) it:
|
||||
# 1. ensures the subdomain exists with document root ~/visit.waterschurch.org
|
||||
# 2. creates ~/visit-private/{data,logs} and a .env from .env.example if missing
|
||||
# 3. rsyncs ./out → ~/visit.waterschurch.org, preserving cPanel's own files
|
||||
# (.user.ini, php.ini, .well-known/) and rebuilding .htaccess from:
|
||||
# cPanel-generated PHP blocks + [pre-launch redirect] + deploy/htaccess.root
|
||||
# 4. installs the 5-minute retry cron job if absent
|
||||
# 5. requests an AutoSSL check and prints the health endpoint
|
||||
# It never touches DNS, the main site, or an existing .env.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SSH_HOST="${SSH_HOST:-waterschurch}"
|
||||
SUBDOMAIN="visit"
|
||||
ROOTDOMAIN="waterschurch.org"
|
||||
FQDN="$SUBDOMAIN.$ROOTDOMAIN"
|
||||
REMOTE_DOCROOT="visit.waterschurch.org" # relative to $HOME on the server
|
||||
REMOTE_PRIVATE="visit-private" # relative to $HOME on the server
|
||||
|
||||
BUILD=1
|
||||
KEEP_REDIRECT=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-build) BUILD=0 ;;
|
||||
--keep-redirect) KEEP_REDIRECT=1 ;;
|
||||
*) echo "unknown option: $arg"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [[ $BUILD -eq 1 ]]; then
|
||||
echo "▶ building"
|
||||
npm run build
|
||||
fi
|
||||
[[ -f out/index.html && -f out/.htaccess && -f out/api/plan-visit.php ]] || { echo "out/ is incomplete — run npm run build"; exit 1; }
|
||||
|
||||
echo "▶ preparing server"
|
||||
ssh "$SSH_HOST" bash -s -- "$SUBDOMAIN" "$ROOTDOMAIN" "$REMOTE_DOCROOT" "$REMOTE_PRIVATE" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
SUB="$1"; ROOT="$2"; DOCROOT="$HOME/$3"; PRIV="$HOME/$4"; FQDN="$SUB.$ROOT"
|
||||
|
||||
if uapi --output=json DomainInfo list_domains 2>/dev/null | grep -q "\"$FQDN\""; then
|
||||
CUR=$(uapi --output=json DomainInfo single_domain_data domain="$FQDN" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["data"].get("documentroot",""))' 2>/dev/null || true)
|
||||
echo " subdomain $FQDN exists (docroot: ${CUR:-unknown})"
|
||||
if [[ -n "$CUR" && "$CUR" != "$DOCROOT" ]]; then
|
||||
echo " ⚠ document root differs — pointing it at $DOCROOT"
|
||||
uapi --output=json SubDomain changedocroot subdomain="$SUB" rootdomain="$ROOT" dir="$DOCROOT" >/dev/null
|
||||
fi
|
||||
else
|
||||
echo " creating subdomain $FQDN → $DOCROOT"
|
||||
uapi --output=json SubDomain addsubdomain domain="$SUB" rootdomain="$ROOT" dir="$DOCROOT" | python3 -c 'import json,sys; r=json.load(sys.stdin)["result"]; print(" ", "ok" if r["status"] else r["errors"])'
|
||||
fi
|
||||
mkdir -p "$DOCROOT"
|
||||
|
||||
# Preserve cPanel-generated .htaccess blocks (PHP ini directives + PHP handler) before we replace the file.
|
||||
if [[ -f "$DOCROOT/.htaccess" ]]; then
|
||||
awk '/^# BEGIN cPanel-generated/,/^# END cPanel-generated/ {print} /^# php -- BEGIN cPanel-generated/,/^# php -- END cPanel-generated/ {print}' "$DOCROOT/.htaccess" > "$DOCROOT/.htaccess.cpanel-blocks"
|
||||
echo " preserved $(grep -c . "$DOCROOT/.htaccess.cpanel-blocks") lines of cPanel-generated .htaccess directives"
|
||||
fi
|
||||
|
||||
mkdir -p "$PRIV/data" "$PRIV/logs"
|
||||
chmod 750 "$PRIV" "$PRIV/data" "$PRIV/logs"
|
||||
echo " private dir: $PRIV"
|
||||
REMOTE
|
||||
|
||||
echo "▶ uploading build"
|
||||
rsync -az --delete \
|
||||
--exclude '/.htaccess' --exclude '/.htaccess.*' \
|
||||
--exclude '/.user.ini' --exclude '/php.ini' --exclude '/.well-known/' \
|
||||
--exclude 'api/data/' \
|
||||
--chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r \
|
||||
out/ "$SSH_HOST:$REMOTE_DOCROOT/"
|
||||
scp -q out/.htaccess "$SSH_HOST:$REMOTE_DOCROOT/.htaccess.visit"
|
||||
scp -q deploy/htaccess.prelaunch-redirect "$SSH_HOST:$REMOTE_DOCROOT/.htaccess.prelaunch"
|
||||
scp -q .env.example "$SSH_HOST:$REMOTE_PRIVATE/.env.example"
|
||||
|
||||
echo "▶ configuring"
|
||||
ssh "$SSH_HOST" bash -s -- "$REMOTE_DOCROOT" "$REMOTE_PRIVATE" "$FQDN" "$KEEP_REDIRECT" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
DOCROOT="$HOME/$1"; PRIV="$HOME/$2"; FQDN="$3"; KEEP="$4"
|
||||
|
||||
# --- .env (never overwritten)
|
||||
if [[ ! -f "$PRIV/.env" ]]; then
|
||||
cp "$PRIV/.env.example" "$PRIV/.env"
|
||||
SALT=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
||||
sed -i "s|^HASH_SALT=.*|HASH_SALT=$SALT|; s|^ALLOWED_ORIGINS=.*|ALLOWED_ORIGINS=|; s|^DEBUG_GEO=.*|DEBUG_GEO=false|" "$PRIV/.env"
|
||||
echo " created $PRIV/.env (INTEGRATION_MODE=mock — add API keys, then set live)"
|
||||
else
|
||||
echo " kept existing $PRIV/.env"
|
||||
fi
|
||||
chmod 640 "$PRIV/.env"
|
||||
|
||||
# --- assemble .htaccess: cPanel blocks + [pre-launch redirect] + site config
|
||||
{
|
||||
[[ -f "$DOCROOT/.htaccess.cpanel-blocks" ]] && cat "$DOCROOT/.htaccess.cpanel-blocks" && echo
|
||||
if [[ "$KEEP" == "1" ]]; then cat "$DOCROOT/.htaccess.prelaunch"; fi
|
||||
sed "s|^# SetEnv VISIT_PRIVATE_DIR .*|SetEnv VISIT_PRIVATE_DIR $PRIV|" "$DOCROOT/.htaccess.visit"
|
||||
} > "$DOCROOT/.htaccess.new"
|
||||
mv -f "$DOCROOT/.htaccess.new" "$DOCROOT/.htaccess"
|
||||
rm -f "$DOCROOT/.htaccess.visit" "$DOCROOT/.htaccess.prelaunch" "$DOCROOT/.htaccess.cpanel-blocks"
|
||||
chmod 644 "$DOCROOT/.htaccess"
|
||||
if [[ "$KEEP" == "1" ]]; then echo " .htaccess: PRE-LAUNCH redirect to Church Center is ACTIVE"; else echo " .htaccess: site is LIVE (no redirect)"; fi
|
||||
|
||||
# --- PHP binary for cron
|
||||
PHP_BIN=$(command -v php || true)
|
||||
for c in /usr/local/bin/php /opt/cpanel/ea-php83/root/usr/bin/php /opt/cpanel/ea-php82/root/usr/bin/php; do
|
||||
[[ -z "$PHP_BIN" && -x "$c" ]] && PHP_BIN="$c"
|
||||
done
|
||||
echo " php: ${PHP_BIN:-NOT FOUND} $($PHP_BIN -r 'echo PHP_VERSION;' 2>/dev/null || true)"
|
||||
|
||||
# --- cron (idempotent)
|
||||
CRON="*/5 * * * * VISIT_PRIVATE_DIR=$PRIV $PHP_BIN $DOCROOT/api/cron/retry-integrations.php >> $PRIV/logs/cron.log 2>&1"
|
||||
if ! crontab -l 2>/dev/null | grep -qF "retry-integrations.php"; then
|
||||
( crontab -l 2>/dev/null || true; echo "$CRON" ) | crontab -
|
||||
echo " cron installed (every 5 minutes)"
|
||||
else
|
||||
echo " cron already present"
|
||||
fi
|
||||
|
||||
# --- SSL
|
||||
if uapi --output=json SSL start_autossl_check >/dev/null 2>&1; then echo " AutoSSL check requested"; else echo " (AutoSSL check not available via UAPI; use cPanel → SSL/TLS Status if needed)"; fi
|
||||
|
||||
echo " build: $(head -1 "$DOCROOT/BUILD.txt")"
|
||||
REMOTE
|
||||
|
||||
echo "▶ verifying"
|
||||
sleep 2
|
||||
printf " https://%s/ → " "$FQDN"; curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" --max-time 20 "https://$FQDN/" || true
|
||||
printf " /api/health.php → "; curl -s --max-time 20 "https://$FQDN/api/health.php" | head -c 600 || true
|
||||
echo
|
||||
if [[ $KEEP_REDIRECT -eq 1 ]]; then
|
||||
echo "Deployed behind the pre-launch redirect. To go live: deploy/deploy.sh --no-build"
|
||||
else
|
||||
echo "Live. Next: fill in $REMOTE_PRIVATE/.env on the server, set INTEGRATION_MODE=live, and run docs/LAUNCH-CHECKLIST.md."
|
||||
fi
|
||||
@@ -0,0 +1,15 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# PRE-LAUNCH: keep sending visitors to the Church Center form until Waters
|
||||
# Church flips the switch. The /api/ endpoints are exempt so the deployment
|
||||
# can be verified (they are rate-limited and same-origin protected). To go live, delete this block (or run deploy/deploy.sh
|
||||
# without --keep-redirect).
|
||||
# ---------------------------------------------------------------------------
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_URI} !^/api/
|
||||
RewriteCond %{REQUEST_URI} !^/404\.html$
|
||||
RewriteCond %{REQUEST_URI} !^/\.well-known/
|
||||
RewriteRule ^(.*)$ https://waterschurch.churchcenter.com/people/forms/21186 [R=302,L]
|
||||
</IfModule>
|
||||
# --------------------------------------------------------------- end prelaunch
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# ============================================================================
|
||||
# visit.waterschurch.org — Apache configuration for the subdomain document root
|
||||
# Copied to out/.htaccess by `npm run build` (scripts/postbuild.mjs).
|
||||
# ============================================================================
|
||||
|
||||
Options -Indexes -MultiViews
|
||||
DirectoryIndex index.html
|
||||
ErrorDocument 404 /404.html
|
||||
# cPanel's default 403 page does not exist in this document root; show our branded page instead (status stays 403).
|
||||
ErrorDocument 403 /404.html
|
||||
|
||||
# --- Private configuration directory for the PHP API -----------------------
|
||||
# Uncomment and set to the absolute path of the folder holding your .env file
|
||||
# (OUTSIDE the document root). If omitted, the API looks for
|
||||
# <parent of document root>/visit-private, e.g. /home/USER/visit-private.
|
||||
# SetEnv VISIT_PRIVATE_DIR /home/USER/visit-private
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# Force HTTPS and the canonical non-www host
|
||||
RewriteCond %{HTTPS} !=on [OR]
|
||||
RewriteCond %{HTTP_HOST} ^www\.visit\.waterschurch\.org$ [NC]
|
||||
RewriteRule ^(.*)$ https://visit.waterschurch.org/$1 [R=301,L]
|
||||
|
||||
# Never serve dot-files (except the ACME challenge folder used for SSL issuance)
|
||||
RewriteCond %{REQUEST_URI} !^/\.well-known/
|
||||
RewriteRule (^|/)\.(?!well-known) - [F,L]
|
||||
|
||||
# Next.js static export: serve /path as /path.html when the file exists
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/$1.html -f
|
||||
RewriteRule ^(.+?)/?$ /$1.html [L]
|
||||
|
||||
# Strip trailing slash on non-directories
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(.+)/$ /$1 [R=301,L]
|
||||
</IfModule>
|
||||
|
||||
# --- Security headers -------------------------------------------------------
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set Permissions-Policy "geolocation=(self), camera=(), microphone=(), payment=()"
|
||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" "expr=%{HTTPS} == 'on'"
|
||||
# CSP is report-only until Waters Church confirms its tag/pixel list. See docs/DEPLOY-CPANEL.md.
|
||||
Header always set Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com https://connect.facebook.net https://analytics.tiktok.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://www.google-analytics.com https://analytics.google.com https://www.facebook.com https://analytics.tiktok.com https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; base-uri 'self'; form-action 'self' sms:; frame-ancestors 'self'"
|
||||
|
||||
# Immutable hashed assets
|
||||
<FilesMatch "\.(js|css|woff2|avif|webp|jpg|jpeg|png|svg|ico)$">
|
||||
Header set Cache-Control "public, max-age=31536000, immutable"
|
||||
</FilesMatch>
|
||||
# HTML and text files: always revalidate
|
||||
<FilesMatch "\.(html|xml|txt|json)$">
|
||||
Header set Cache-Control "public, max-age=0, must-revalidate"
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
||||
# --- Compression -----------------------------------------------------------
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/html text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml font/woff2
|
||||
</IfModule>
|
||||
<IfModule mod_brotli.c>
|
||||
AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml
|
||||
</IfModule>
|
||||
|
||||
# --- MIME types some cPanel builds miss -------------------------------------
|
||||
AddType image/avif .avif
|
||||
AddType image/webp .webp
|
||||
AddType font/woff2 .woff2
|
||||
|
||||
# --- Expires fallback (when mod_headers is unavailable) ---------------------
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive On
|
||||
ExpiresByType image/avif "access plus 1 year"
|
||||
ExpiresByType image/webp "access plus 1 year"
|
||||
ExpiresByType image/jpeg "access plus 1 year"
|
||||
ExpiresByType image/png "access plus 1 year"
|
||||
ExpiresByType image/svg+xml "access plus 1 year"
|
||||
ExpiresByType font/woff2 "access plus 1 year"
|
||||
ExpiresByType text/css "access plus 1 year"
|
||||
ExpiresByType application/javascript "access plus 1 year"
|
||||
ExpiresByType text/html "access plus 0 seconds"
|
||||
</IfModule>
|
||||
@@ -0,0 +1,194 @@
|
||||
# Deploying to cPanel (site administrator guide)
|
||||
|
||||
This guide assumes the standard cPanel layout `/home/USER/`. Replace `USER` with the account's
|
||||
username throughout. Nothing here changes DNS or publishes anything until step 8.
|
||||
|
||||
> **Current state (2026-09-22):** `visit.waterschurch.org` already resolves to the same server as
|
||||
> waterschurch.org (108.160.152.27) and currently 301-redirects to a Church Center form
|
||||
> (`waterschurch.churchcenter.com/people/forms/21186`). That redirect lives either in the main
|
||||
> site's `.htaccess` or in a cPanel **Redirects** entry and must be removed when this site goes live.
|
||||
|
||||
## Fastest path: `deploy/deploy.sh` (SSH)
|
||||
|
||||
If you have SSH access (alias `waterschurch` in `~/.ssh/config`), one command does steps 2, 5, 6 and 7 below:
|
||||
|
||||
```bash
|
||||
deploy/deploy.sh --keep-redirect # deploy, but keep visitors on the Church Center form (pre-launch)
|
||||
deploy/deploy.sh # build, deploy and go live (removes the redirect)
|
||||
deploy/deploy.sh --no-build # re-upload the existing ./out
|
||||
```
|
||||
|
||||
It preserves cPanel's own `.htaccess` blocks and `.user.ini`, never overwrites an existing
|
||||
`~/visit-private/.env`, and prints the health check when done. The manual steps below describe
|
||||
what it does, for File Manager users or troubleshooting.
|
||||
|
||||
**Status 2026-09-22 (evening): LIVE.** Deployed with `deploy/deploy.sh`; the Church Center redirect is
|
||||
gone. `INTEGRATION_MODE=live` with Mailchimp (audience "Waters Church", datacenter `us2`) and Sinch
|
||||
(all five campus sub-accounts verified). The 12 merge fields exist in the audience. A real email-only
|
||||
test lead succeeded end to end; the SMS path is verified against Sinch auth but a live text has not yet
|
||||
been sent. Journey "New Visit" (id 5842) is still a draft and triggers only on `Visit:Apollo Beach`.
|
||||
|
||||
## 0. What you will need
|
||||
|
||||
* cPanel login for the waterschurch.org account
|
||||
* The build package: run `npm run build && npm run package` → `dist/visit-waterschurch-<version>-<date>.zip`
|
||||
* Filled-in `.env` values (Mailchimp key, Sinch key/secret, etc.) — see `.env.example`
|
||||
|
||||
## 1. Check the account's PHP
|
||||
|
||||
cPanel → **Software → MultiPHP Manager** (or *Select PHP Version*).
|
||||
|
||||
* Set the domain (once created) to **PHP 8.1, 8.2 or 8.3**.
|
||||
* **MultiPHP INI Editor / Select PHP Version → Extensions**: ensure `curl`, `pdo_sqlite`, `sqlite3`,
|
||||
`mbstring`, `json`, `openssl` are enabled. (`pdo_mysql` only if you choose MySQL storage.)
|
||||
|
||||
Node.js is **not** required. If the account has "Setup Node.js App", ignore it.
|
||||
|
||||
## 2. Create the private configuration folder (outside the web root)
|
||||
|
||||
cPanel → **Files → File Manager** → navigate to `/home/USER/` (the home directory, *not*
|
||||
`public_html`).
|
||||
|
||||
1. Create folder **`visit-private`**.
|
||||
2. Inside it create folders **`data`** and **`logs`**.
|
||||
3. Upload `.env.example` from the package (`dist/env.example.txt`), rename it to **`.env`**, and edit
|
||||
it in File Manager: fill in real values, set `INTEGRATION_MODE=live` only when ready to go live,
|
||||
and set a long random `HASH_SALT`.
|
||||
4. Permissions (right-click → *Change Permissions*): folders `0750`, the `.env` file `0640`.
|
||||
PHP runs as your cPanel user under suPHP/LSAPI, so it can read and write these while the web
|
||||
server cannot serve them.
|
||||
|
||||
If your host runs PHP as a different user (rare), use `0755`/`0644` and rely on the folder being
|
||||
outside the document root.
|
||||
|
||||
## 3. Create the subdomain
|
||||
|
||||
cPanel → **Domains → Domains** (or *Subdomains* on older versions) → **Create a New Domain**:
|
||||
|
||||
* Domain: `visit.waterschurch.org`
|
||||
* Untick "Share document root"; set **Document Root** to `/home/USER/visit.waterschurch.org`
|
||||
* Submit. cPanel creates the folder and the DNS `A` record automatically because the zone is hosted
|
||||
here (`ns1/ns2.waterschurch.org`). The record already exists and points at this server, so nothing
|
||||
external changes.
|
||||
|
||||
Then remove the old redirect:
|
||||
|
||||
* cPanel → **Domains → Redirects**: delete any entry for `visit.waterschurch.org`, **or**
|
||||
* File Manager → `public_html/.htaccess`: remove the `RewriteCond %{HTTP_HOST} ^visit\.` block that
|
||||
redirects to churchcenter.com.
|
||||
|
||||
Do this at the moment you are ready to launch (step 8); until then the redirect keeps working.
|
||||
|
||||
## 4. Enable SSL
|
||||
|
||||
cPanel → **Security → SSL/TLS Status** → tick `visit.waterschurch.org` → **Run AutoSSL**.
|
||||
Wait for the green padlock (usually under 5 minutes). If AutoSSL fails with a DCV error, make sure
|
||||
the document root exists and nothing blocks `/.well-known/` (our `.htaccess` explicitly allows it).
|
||||
|
||||
Verify: `https://visit.waterschurch.org/BUILD.txt` shows the build id once files are uploaded
|
||||
(step 5). Force-HTTPS is handled by the site's own `.htaccess`; you do **not** need cPanel's
|
||||
"Force HTTPS Redirect" toggle, but enabling it is harmless.
|
||||
|
||||
## 5. Upload the build
|
||||
|
||||
1. File Manager → open `/home/USER/visit.waterschurch.org/`. Delete the placeholder `cgi-bin`
|
||||
folder if present.
|
||||
2. **Upload** the zip from `dist/`, then right-click → **Extract** into this folder. Delete the zip.
|
||||
3. Enable **Settings → Show Hidden Files (dotfiles)** and confirm `.htaccess` is present at the root
|
||||
and inside `api/lib`, `api/cron`, `api/config`.
|
||||
4. Tell the API where the private folder is. Either:
|
||||
* leave the default (`/home/USER/visit-private` — the folder *next to* the document root), **or**
|
||||
* edit `/home/USER/visit.waterschurch.org/.htaccess` and uncomment
|
||||
`SetEnv VISIT_PRIVATE_DIR /home/USER/visit-private` with the real path.
|
||||
5. Permissions: folders `0755`, files `0644` (File Manager's defaults after extraction are fine).
|
||||
|
||||
The upload never requires editing generated files. To update the site later, rebuild locally and
|
||||
repeat steps 5.2–5.3 (or use *File Manager → Upload* with "Overwrite existing files"). An alternative
|
||||
for frequent updates is cPanel's **Git Version Control** pointed at a repo whose `out/` is committed,
|
||||
or SFTP with `rsync --delete out/ USER@host:visit.waterschurch.org/`.
|
||||
|
||||
## 6. Verify the API
|
||||
|
||||
Open `https://visit.waterschurch.org/api/health.php`. You should see `"ok": true` and:
|
||||
|
||||
```
|
||||
"private_dir_found": true, "env_file_found": true, "data_dir_writable": true,
|
||||
"database": "ok (sqlite)", "campuses_loaded": 6, "https": true
|
||||
```
|
||||
|
||||
`integration_mode` shows `mock` until you change it in `.env`. Fix anything `false` before continuing
|
||||
(most often: wrong `VISIT_PRIVATE_DIR` path or `data` folder not writable).
|
||||
|
||||
Confirm private paths are blocked (each must return **403**):
|
||||
|
||||
* `https://visit.waterschurch.org/api/lib/Config.php`
|
||||
* `https://visit.waterschurch.org/api/config/campuses.json`
|
||||
* `https://visit.waterschurch.org/api/cron/retry-integrations.php`
|
||||
|
||||
## 7. Cron job for retries
|
||||
|
||||
cPanel → **Advanced → Cron Jobs** → add, every 5 minutes (`*/5 * * * *`):
|
||||
|
||||
```
|
||||
VISIT_PRIVATE_DIR=/home/USER/visit-private /usr/local/bin/php /home/USER/visit.waterschurch.org/api/cron/retry-integrations.php >> /home/USER/visit-private/logs/cron.log 2>&1
|
||||
```
|
||||
|
||||
Check the PHP binary path with **MultiPHP Manager** (often `/usr/local/bin/php`, `/opt/cpanel/ea-php82/root/usr/bin/php`, or `/usr/local/bin/ea-php82`). The job re-sends any Mailchimp/Sinch
|
||||
delivery that failed transiently (up to 5 attempts with backoff) and, if `LEAD_RETENTION_DAYS` is set,
|
||||
deletes fully-processed leads older than that.
|
||||
|
||||
Optional weekly export for staff (contains personal data — download and delete promptly):
|
||||
|
||||
```
|
||||
VISIT_PRIVATE_DIR=/home/USER/visit-private /usr/local/bin/php /home/USER/visit.waterschurch.org/api/cron/export-leads.php --since=$(date -d '7 days ago' +%F) > /home/USER/visit-private/data/leads-week.csv
|
||||
```
|
||||
|
||||
## 8. Go live
|
||||
|
||||
1. Set `INTEGRATION_MODE=live` in `/home/USER/visit-private/.env`.
|
||||
2. Remove the Church Center redirect (step 3).
|
||||
3. Submit one real test lead with your own email and mobile: confirm the Mailchimp contact + tags
|
||||
and the confirmation text arrive. Reply STOP to test opt-out routing, then re-subscribe.
|
||||
4. Run through [LAUNCH-CHECKLIST.md](LAUNCH-CHECKLIST.md).
|
||||
|
||||
## 9. Updating content later
|
||||
|
||||
| Change | Where | Then |
|
||||
| --- | --- | --- |
|
||||
| Service times, address, phone, SMS number, campus status | `config/campuses.json` | `npm run build` → re-upload |
|
||||
| Logo | `assets/source/logos/` (same file names) | `npm run build` → re-upload |
|
||||
| Photos | `assets/source/photos/` (same file names) | `npm run build` → re-upload |
|
||||
| FAQs, kids info, headlines, consent text | `src/content/*.ts` (bump `consent.version` if consent text changes) | `npm run build` → re-upload |
|
||||
| Central contact number, privacy URL | `.env` (`NEXT_PUBLIC_*`) | `npm run build` → re-upload |
|
||||
| API keys, mode, providers | `/home/USER/visit-private/.env` | takes effect immediately, no rebuild |
|
||||
| SMS template | `SINCH_CONFIRMATION_TEMPLATE` in server `.env` | immediate |
|
||||
|
||||
## 10. Files, permissions and logs
|
||||
|
||||
```
|
||||
/home/USER/
|
||||
├── visit.waterschurch.org/ document root (0755) — everything in out/
|
||||
│ ├── .htaccess HTTPS + canonical redirects, caching, security headers, 404
|
||||
│ ├── index.html, 404.html, _next/, images/
|
||||
│ └── api/ PHP endpoints (public) + lib/ cron/ config/ (403)
|
||||
└── visit-private/ NOT web accessible (0750)
|
||||
├── .env secrets (0640)
|
||||
├── data/leads.sqlite lead store (created automatically)
|
||||
├── data/retry.lock
|
||||
└── logs/app.log, cron.log JSON lines; personal data and secrets are redacted
|
||||
```
|
||||
|
||||
Rotate `app.log` occasionally (cPanel cron: `find /home/USER/visit-private/logs -name '*.log' -size +20M -delete`)
|
||||
or simply download and clear it.
|
||||
|
||||
## 11. Optional: MySQL instead of SQLite
|
||||
|
||||
cPanel → **Databases → MySQL Database Wizard**: create `USER_visit` + a user with ALL PRIVILEGES.
|
||||
In `.env`: `DATABASE_DSN=mysql:host=localhost;dbname=USER_visit;charset=utf8mb4`,
|
||||
`DATABASE_USER`, `DATABASE_PASSWORD`. Tables are created automatically on first request.
|
||||
|
||||
## 12. Content Security Policy
|
||||
|
||||
`.htaccess` ships a **report-only** CSP covering GTM, GA4, Meta, TikTok and Turnstile. After
|
||||
confirming the tags you actually use, change `Content-Security-Policy-Report-Only` to
|
||||
`Content-Security-Policy` and remove hosts you do not need.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Launch checklist
|
||||
|
||||
## A. Items that require Waters Church approval before launch
|
||||
|
||||
Nothing below was fabricated; each item is either unverified or a legal/policy decision.
|
||||
|
||||
### Facts to confirm (config/campuses.json → `verify`)
|
||||
- [ ] **Coordinates** for every campus (derived from Google Maps links / OpenStreetMap; spot-check each pin).
|
||||
- [ ] **North Attleboro service times** — campus page says 7:30, 9:30, 11:30 AM; the locations index page shows "11:30p" (assumed typo).
|
||||
- [ ] **Parking and arrival notes** for each campus (currently empty; generic copy is shown instead).
|
||||
- [ ] **Woonsocket and Southcoast phone numbers** — the main office number is used because the campus pages list none.
|
||||
- [ ] **Orlando** — marked `launching` (launch team only) and excluded from recommendations. Flip `status` to `open` and add an SMS number when it opens.
|
||||
- [ ] **Central "Text Us" number** (`NEXT_PUBLIC_CENTRAL_SMS_NUMBER`, default +1 508-695-1300) — who answers general texts?
|
||||
- [ ] **Norwood** uses `info@waterschurchnorwood.org` and 781-762-3357 per its own page — confirm these are current.
|
||||
|
||||
### Content placeholders (src/content)
|
||||
- [ ] `faqs.ts` items flagged `confirm: true`: service length ("a little over an hour"), parking ("free on-site parking at every location"), accessibility ("step-free entrances").
|
||||
- [ ] `kids.ts` items flagged `placeholder: true`: first-time check-in steps, "arrive 15 minutes early", what to bring, special-needs accommodations.
|
||||
- [ ] `testimonials.ts` is empty → section is hidden. Add real, permissioned quotes or leave hidden.
|
||||
- [ ] Headline and copy tone (`copy.ts`).
|
||||
|
||||
### Legal / compliance (not legal advice — route to Waters Church's adviser)
|
||||
- [ ] **SMS consent language** (`consent.ts` → `sms.detail`): TCPA/CTIA disclosures, frequency, "Msg & data rates may apply", STOP/HELP, consent not a condition. Confirm the number is registered (10DLC campaign / toll-free verification) for this use case.
|
||||
- [ ] **Email consent language** and single vs. double opt-in (`MAILCHIMP_DOUBLE_OPTIN`).
|
||||
- [ ] **Privacy Policy** at waterschurch.org/privacy covers this subdomain, SMS, geolocation estimate, analytics and ad pixels. Add a **Terms** URL (`NEXT_PUBLIC_TERMS_URL`) if one exists.
|
||||
- [ ] **Data retention**: set `LEAD_RETENTION_DAYS` (0 = keep forever). Decide who may run `export-leads.php` and where exports live.
|
||||
- [ ] **Analytics consent mode**: `implied` (default) vs `opt-in` banner.
|
||||
- [ ] **Location estimation** wording (`consent.ts` → `locationBlurb`).
|
||||
- [ ] Bump `consent.version` if any consent text changes.
|
||||
|
||||
## B. Post-deployment verification
|
||||
|
||||
### Hosting
|
||||
- [ ] `https://visit.waterschurch.org/` loads; `http://` and `www.` redirect (301) to `https://visit.waterschurch.org/`.
|
||||
- [ ] SSL padlock valid (AutoSSL) — check with https://www.ssllabs.com/ssltest/.
|
||||
- [ ] `/BUILD.txt` shows the expected build; `/404.html` and a random URL return the branded 404.
|
||||
- [ ] `/api/health.php` → `"ok": true`, `integration_mode: "live"`.
|
||||
- [ ] `/api/lib/Config.php`, `/api/config/campuses.json`, `/api/cron/retry-integrations.php` → **403**.
|
||||
- [ ] Response headers include `Strict-Transport-Security`, `X-Content-Type-Options`, cache headers (`immutable` on `/_next/static`, `must-revalidate` on HTML), `Content-Encoding: br|gzip`.
|
||||
- [ ] Old Church Center redirect removed.
|
||||
|
||||
### Location detection
|
||||
- [ ] On a phone off Wi-Fi: a campus is *recommended* with the "based on your general area" badge.
|
||||
- [ ] Deny "Use my precise location" → friendly message, page still usable.
|
||||
- [ ] ZIP search: 02763 → North Attleboro, 02062 → Norwood, 02895 → Woonsocket, 02878 → Southcoast, 33572 → Apollo Beach, 32789 → Apollo Beach (far away notice), 90210 → far away notice.
|
||||
- [ ] Bad ZIP (`abc`, `1234`) → inline error, no crash.
|
||||
- [ ] Choose a different campus → form step 1 updates to match.
|
||||
|
||||
### Form, consent and integrations (use your own email/phone)
|
||||
- [ ] Email consent only → Mailchimp contact created with tags; **no** text sent.
|
||||
- [ ] SMS consent without phone → blocked with an inline error.
|
||||
- [ ] SMS + phone → confirmation text arrives from the **campus** number within a minute; Mailchimp `Visit:SMS Opt-In` tag present.
|
||||
- [ ] Neither box → lead stored (export shows it), nothing sent.
|
||||
- [ ] Reply STOP to the text → opt-out confirmed by Sinch; resubscribe afterwards.
|
||||
- [ ] Double-tap "Send" / refresh and resubmit → one lead, one text (`duplicate: true` in the network response).
|
||||
- [ ] Temporarily set a wrong `SINCH_API_SECRET` → submit → confirmation still shows, `sms_status = retry`, cron log shows retry; fix the secret → text arrives on the next cron run.
|
||||
- [ ] Confirmation screen shows campus, times, directions, "Text Us a Question" with the campus number.
|
||||
- [ ] Mailchimp journey/automation fires for the new contact.
|
||||
|
||||
### Attribution and analytics
|
||||
- [ ] Visit with `?utm_source=facebook&utm_campaign=test&fbclid=abc&v=b`, submit → export shows the attribution JSON and `variant = b`; Mailchimp tags include `Visit:Platform Facebook`, `Visit:Campaign test`.
|
||||
- [ ] Visit with no parameters and with malformed ones (`?utm_source=<script>`) → still works, values sanitised.
|
||||
- [ ] GTM/GA4 debug view shows `landing_page_view`, `campus_recommended`, `campus_selected`, `plan_visit_started`, `plan_visit_step_completed`, `plan_visit_submitted`, `plan_visit_success`, `text_us_clicked`, `directions_clicked` with `campus`, `platform`, `campaign`, `device_category`, `variant`.
|
||||
- [ ] Conversion events fire **only** after `plan_visit_success` (check Meta Events Manager / Google Ads test conversion).
|
||||
- [ ] No email/phone/name appears in any analytics payload.
|
||||
|
||||
### Text Us
|
||||
- [ ] iPhone and Android: header, hero, floating button and campus cards open Messages with the pre-filled text, editable, to the right number.
|
||||
- [ ] Desktop: the `sms:` link degrades gracefully (Messages app on macOS, otherwise no-op) — consider a visible phone number if desktop traffic is significant.
|
||||
|
||||
### Consent records
|
||||
- [ ] Export a lead: `consent_version`, `consent_at`, `email_consent`, `sms_consent`, hashed IP present; no raw IP or precise coordinates stored.
|
||||
|
||||
### Performance & accessibility
|
||||
- [ ] Lighthouse mobile (Chrome DevTools, "Slow 4G"): Performance ≥ 90, Accessibility ≥ 95, no CLS from hero/fonts.
|
||||
- [ ] 320 px, 375 px, 430 px, 768 px, 1280 px widths: no horizontal scroll, tap targets ≥ 44 px.
|
||||
- [ ] Keyboard only: skip link → header → campus cards → FAQ accordions → form steps → submit; focus always visible.
|
||||
- [ ] Screen reader: form errors announced (`role="alert"`), step heading focused on step change, confirmation announced (`role="status"`).
|
||||
- [ ] `prefers-reduced-motion`: no animation.
|
||||
|
||||
### Operations
|
||||
- [ ] Cron job runs every 5 minutes (`cron.log` grows with `retry:` lines).
|
||||
- [ ] Someone owns `app.log` review and the leads export cadence.
|
||||
- [ ] `.env` backed up somewhere safe (it is not in the repo).
|
||||
@@ -0,0 +1,188 @@
|
||||
# Setup guide (developers)
|
||||
|
||||
## 1. Requirements
|
||||
|
||||
* Node.js 20+ and npm
|
||||
* PHP 8.1+ with `curl`, `pdo_sqlite`, `mbstring`, `json` (macOS: `brew install php`)
|
||||
* `zip` (for `npm run package`)
|
||||
|
||||
## 2. Run locally
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
cp .env.example private/.env
|
||||
```
|
||||
|
||||
Edit `.env` (frontend build settings):
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_API_BASE=http://127.0.0.1:8080/api
|
||||
```
|
||||
|
||||
Edit `private/.env` (PHP API; stays in mock mode by default):
|
||||
|
||||
```
|
||||
INTEGRATION_MODE=mock
|
||||
ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
DEBUG_GEO=true # lets you fake a location: /api/locate.php?debug_lat=42.19&debug_lng=-71.19
|
||||
```
|
||||
|
||||
Then in two terminals:
|
||||
|
||||
```bash
|
||||
npm run dev # http://localhost:3000
|
||||
npm run dev:api # http://127.0.0.1:8080/api/health.php
|
||||
```
|
||||
|
||||
Open http://localhost:3000. Submit the form: in mock mode the API stores the lead in
|
||||
`private/data/leads.sqlite` and logs *what it would have sent* to `private/logs/app.log`
|
||||
without contacting Mailchimp or Sinch.
|
||||
|
||||
### Preview the exact production build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
php -S 127.0.0.1:8080 -t out
|
||||
```
|
||||
|
||||
This serves the static export and the PHP API from one origin, like cPanel will.
|
||||
|
||||
## 3. Test without sending messages
|
||||
|
||||
* `INTEGRATION_MODE=mock` (default) never calls Mailchimp or Sinch. Responses carry
|
||||
`"status": "mock"` so the confirmation screen still renders correctly.
|
||||
* `npm test` runs the TypeScript suite (jsdom).
|
||||
* `npm run test:php` spins up fake Mailchimp/Sinch/geocode servers on `127.0.0.1:8099`
|
||||
and the real API on `127.0.0.1:8098`, then checks validation, consent enforcement,
|
||||
idempotency, partial failure + cron retry, and every HTTP endpoint end to end.
|
||||
* To inspect stored leads: `php public/api/cron/export-leads.php > leads.csv`
|
||||
(run with `VISIT_PRIVATE_DIR=private` locally).
|
||||
|
||||
## 4. Enter campus details
|
||||
|
||||
Everything campus-related is in **`config/campuses.json`**. Each entry:
|
||||
|
||||
| Field | Notes |
|
||||
| --- | --- |
|
||||
| `id`, `name`, `label` | `id` is stable and appears in analytics + Mailchimp tags |
|
||||
| `status` | `open` (selectable), `launching` (shown as "coming soon"), `hidden` |
|
||||
| `address`, `coordinates` | Coordinates drive the "closest campus" math. Verify on Google Maps (right-click → copy coordinates). |
|
||||
| `serviceTimes[]` | `{ day, times[], note? }` — times are display strings like `"9:30 AM"` |
|
||||
| `phone`, `smsNumber` | E.164. `smsNumber` must be SMS-enabled on the Sinch Engage account |
|
||||
| `sinchAccountId` | Sinch Engage sub-account used as the sender (`Account` header) |
|
||||
| `directionsUrl`, `campusUrl` | Google Maps directions link; campus page on waterschurch.org |
|
||||
| `arrivalNotes`, `parkingNotes`, `kidsNotes` | Optional short text shown on the campus card |
|
||||
| `image` | Photo key; put the file at `assets/source/photos/campus-<id>.jpg`, run `npm run images` |
|
||||
| `verify[]` | Reminder list of fields Waters Church still needs to confirm (documentation only) |
|
||||
|
||||
After editing: `npm run build` and upload `out/` again (the API reads `out/api/config/campuses.json`).
|
||||
|
||||
Copy and FAQ text live in `src/content/` (`copy.ts`, `faqs.ts`, `kids.ts`, `consent.ts`,
|
||||
`testimonials.ts`). Site-wide settings (phone, email, privacy URL, central SMS number) are in
|
||||
`src/config/site.ts` and can be overridden by `NEXT_PUBLIC_*` variables.
|
||||
|
||||
## 5. Connect Mailchimp
|
||||
|
||||
1. In Mailchimp: **Profile → Extras → API keys → Create a key**. The key ends in `-usXX`; that is your
|
||||
`MAILCHIMP_SERVER_PREFIX`.
|
||||
2. **Audience → Settings → Audience name and defaults** → copy the **Audience ID**.
|
||||
3. Create these **merge fields** in the audience (Settings → Audience fields): `CAMPUS`, `VISITDATE`,
|
||||
`SERVICE`, `ADULTS`, `KIDS`, `KIDAGES`, `QUESTIONS`, `UTMSOURCE`, `UTMCAMP`, `LPVARIANT`, `SUBID`,
|
||||
`PHONE` (text). Unknown fields are ignored (`skip_merge_validation=true`), but they only store data
|
||||
if they exist.
|
||||
4. Tags applied automatically (prefix configurable via `MAILCHIMP_TAG_PREFIX`, default `Visit:`),
|
||||
matching the church's existing `Visit:<Campus>` convention:
|
||||
`Visit:<Campus>` (e.g. `Visit:Norwood`), `Visit:First-Time Visitor`, `Visit:Source visit.waterschurch.org`,
|
||||
`Visit:Platform <Google|Facebook|Instagram|TikTok|X|…>`, `Visit:Campaign <utm_campaign>`,
|
||||
`Visit:Visit <date>` or `Visit:Service <time>`, `Visit:Has Kids`, `Visit:SMS Opt-In`.
|
||||
5. Welcome journey — two options:
|
||||
* **Tag-triggered** (simplest): build a Customer Journey whose starting point is
|
||||
"Tag added → `Visit:First-Time Visitor`" (all campuses) or `Visit:<Campus>` (one per campus).
|
||||
The "New Visit" journey created 2026-09-22 triggers on `Visit:Apollo Beach` only.
|
||||
* **API-triggered**: add an "API trigger" starting point, copy the journey id and step id from
|
||||
the trigger's URL into `MAILCHIMP_JOURNEY_ID` / `MAILCHIMP_JOURNEY_STEP_ID`.
|
||||
6. Consent: contacts are only created when the visitor ticks the **email** box. `status_if_new` is
|
||||
`subscribed` (single opt-in). Set `MAILCHIMP_DOUBLE_OPTIN=true` to require Mailchimp's confirmation
|
||||
email instead. Existing contacts are updated, never duplicated (upsert by email hash).
|
||||
|
||||
## 6. Connect Sinch Engage
|
||||
|
||||
The church's Sinch Engage account (formerly MessageMedia) has one sub-account per campus with an
|
||||
SMS-enabled number; these are already in `config/campuses.json`:
|
||||
|
||||
| Campus | Sub-account | Number |
|
||||
| --- | --- | --- |
|
||||
| North Attleboro | `WatersChurch_OLA_0007` | +1 508-695-1300 |
|
||||
| Norwood | `WatersChurch_OLA_0002` | +1 781-762-3357 |
|
||||
| Woonsocket | `WatersChurch_OLA_0005` | +1 833-241-4837 (toll-free) |
|
||||
| Southcoast | `WatersChurch_OLA_0006` | +1 508-809-3490 |
|
||||
| Apollo Beach | `WatersChurch_OLA_0003` | +1 813-642-6979 |
|
||||
| Parent (fallback) | `WatersChurch_OLA_0001` | +1 774-643-6322, +1 781-762-4007 (10DLC) |
|
||||
|
||||
1. In the Sinch Engage web portal, create **API credentials** on the *parent* account
|
||||
(Basic auth: key + secret). Parent keys may send on behalf of sub-accounts via the `Account` header,
|
||||
which the adapter sets automatically (`SINCH_USE_CAMPUS_SUBACCOUNTS=true`).
|
||||
2. Set `SINCH_API_BASE` to the regional host for the account (US: `https://us.app.api.sinch.com`).
|
||||
3. Endpoint used: `POST /v1/messages` with `content`, `destination_number`, `source_number`,
|
||||
`format: "SMS"`, `delivery_report: true`, `metadata`, `message_expiry_timestamp`.
|
||||
Success = HTTP 202 **and** a `message_id` in the response; anything else is recorded as failed
|
||||
and retried by cron if transient.
|
||||
4. Confirmation text (edit via `SINCH_CONFIRMATION_TEMPLATE`; placeholders `{{first_name}}`,
|
||||
`{{campus_name}}`, `{{service_or_date}}`, `{{address}}`):
|
||||
|
||||
> Hi {{first_name}} — we're excited that you're considering Waters Church {{campus_name}}.
|
||||
> Your visit details: {{service_or_date}}. Have a question? Reply to this message. Reply STOP to opt out.
|
||||
|
||||
5. **Opt-out handling:** confirm in the Sinch Engage portal that STOP/HELP keyword handling is enabled
|
||||
for each campus number, and that replies route to the campus inbox. The landing page never
|
||||
sends to a number that has not explicitly ticked the SMS box on this form.
|
||||
6. Optional: `SINCH_INTERNAL_NOTIFY_NUMBER` sends staff a short heads-up per lead (first name + campus).
|
||||
|
||||
## 7. Configure tracking
|
||||
|
||||
All tracking is off until an ID is present. Set in `.env` before `npm run build`:
|
||||
|
||||
| Variable | Loads |
|
||||
| --- | --- |
|
||||
| `NEXT_PUBLIC_GTM_ID` | Google Tag Manager (recommended: manage every pixel there) |
|
||||
| `NEXT_PUBLIC_GA4_ID` | GA4 directly (if not using GTM) |
|
||||
| `NEXT_PUBLIC_GADS_CONVERSION` | Google Ads conversion (`AW-xxx/label`) fired on `plan_visit_success` |
|
||||
| `NEXT_PUBLIC_META_PIXEL_ID` | Meta Pixel (`PageView` + `Lead` on success) |
|
||||
| `NEXT_PUBLIC_TIKTOK_PIXEL_ID` | TikTok Pixel (`SubmitForm` on success) |
|
||||
| `NEXT_PUBLIC_ANALYTICS_CONSENT_MODE` | `implied` (default) or `opt-in` (shows an Allow/Decline prompt) |
|
||||
|
||||
Every event is pushed to `window.dataLayer` with these properties: `variant`, `source`, `medium`,
|
||||
`campaign`, `content`, `platform`, `device_category`, plus event-specific ones such as `campus`
|
||||
and `selection_source`. Events: `landing_page_view`, `campus_recommended`,
|
||||
`location_permission_requested`, `campus_selected`, `plan_visit_started`,
|
||||
`plan_visit_step_completed`, `plan_visit_submitted`, `plan_visit_success` (server-confirmed),
|
||||
`plan_visit_error`, `text_us_clicked`, `directions_clicked`. No names, emails or phone numbers are
|
||||
ever sent to analytics.
|
||||
|
||||
In GTM, create a Custom Event trigger for `plan_visit_success` and attach your conversion tags.
|
||||
|
||||
Attribution captured per visitor (session storage) and stored with the lead: `utm_source`,
|
||||
`utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `gclid`, `gbraid`, `wbraid`, `fbclid`,
|
||||
`ttclid`, `twclid`, landing-page `variant` (`?v=b`), initial referrer, first landing URL.
|
||||
|
||||
## 8. Location services
|
||||
|
||||
* `GEOIP_PROVIDER` — `ipapi_co` (default, no key up to ~1,000 lookups/day), `ipinfo` (key),
|
||||
`ip_api_com` (key for HTTPS), `header` (Cloudflare geolocation headers), or `none`.
|
||||
Results are rounded to ~1 km and cached for an hour by hashed IP. They are shown to the visitor as
|
||||
a *recommendation*, never stored with the lead.
|
||||
* `GEOCODE_PROVIDER` — `zippopotam` (default, free), `nominatim`, or `google` (key).
|
||||
ZIP centroids are cached for 30 days.
|
||||
* Precise browser geolocation is requested **only** when the visitor taps "Use my precise location"
|
||||
and is never sent to the server.
|
||||
|
||||
## 9. Anti-spam
|
||||
|
||||
Honeypot field + minimum time-on-form (`MIN_FORM_SECONDS`) + per-IP rate limits
|
||||
(`RATE_LIMIT_SUBMIT_PER_10MIN`, `RATE_LIMIT_SUBMIT_PER_DAY`) are always on. Optional Cloudflare
|
||||
Turnstile: set `NEXT_PUBLIC_TURNSTILE_SITE_KEY` (build) and `TURNSTILE_SECRET_KEY` (server).
|
||||
|
||||
## 10. Deploy
|
||||
|
||||
See [DEPLOY-CPANEL.md](DEPLOY-CPANEL.md).
|
||||
@@ -0,0 +1,17 @@
|
||||
import nextConfig from "eslint-config-next";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const config = [
|
||||
...nextConfig,
|
||||
...nextTs,
|
||||
{
|
||||
rules: {
|
||||
// Images are pre-optimized at build time (scripts/optimize-images.mjs) and served statically;
|
||||
// next/image adds nothing in a static export with `images.unoptimized`.
|
||||
"@next/next/no-img-element": "off",
|
||||
},
|
||||
},
|
||||
{ ignores: ["node_modules/**", ".next/**", "out/**", "public/**", "scripts/**", "tests/php/**"] },
|
||||
];
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
/**
|
||||
* visit.waterschurch.org is deployed to cPanel/Apache as a static export.
|
||||
* All server work (form processing, Mailchimp, Sinch, geolocation) lives in
|
||||
* PHP endpoints under public/api, which Next copies verbatim into ./out/api.
|
||||
*/
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
trailingSlash: false,
|
||||
reactStrictMode: true,
|
||||
poweredByHeader: false,
|
||||
images: {
|
||||
// Static export: images are pre-optimized at build time by scripts/optimize-images.mjs
|
||||
unoptimized: true,
|
||||
},
|
||||
env: {
|
||||
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL ?? "https://visit.waterschurch.org",
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "visit-waterschurch",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Plan Your Visit landing page for Waters Church (visit.waterschurch.org)",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev:api": "php -S 127.0.0.1:8080 -t public",
|
||||
"build": "node scripts/optimize-images.mjs && next build && node scripts/postbuild.mjs",
|
||||
"start": "npx serve out",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:php": "php tests/php/run.php",
|
||||
"images": "node scripts/optimize-images.mjs",
|
||||
"package": "node scripts/package.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"libphonenumber-js": "^1.13.13",
|
||||
"next": "^16.3.5",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^16.3.5",
|
||||
"jsdom": "^30.1.1",
|
||||
"sharp": "^0.35.4",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
const config = { plugins: { "@tailwindcss/postcss": {} } };
|
||||
export default config;
|
||||
@@ -0,0 +1,16 @@
|
||||
# private/ (local development only)
|
||||
|
||||
This folder stands in for the server's private directory (`/home/USER/visit-private`).
|
||||
It is git-ignored except for this README.
|
||||
|
||||
For local development:
|
||||
|
||||
```bash
|
||||
cp .env.example private/.env
|
||||
```
|
||||
|
||||
The PHP API (started with `npm run dev:api`) will find `private/.env`, create
|
||||
`private/data/leads.sqlite` and `private/logs/app.log` automatically.
|
||||
|
||||
In production, **never** place this folder inside the document root. See
|
||||
`docs/DEPLOY-CPANEL.md`.
|
||||
@@ -0,0 +1,19 @@
|
||||
# /api — PHP endpoints. Everything under lib/, cron/, config/ and data/ is private.
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteRule ^(lib|cron|config|data)(/|$) - [F,L]
|
||||
</IfModule>
|
||||
|
||||
# Belt and braces for servers without mod_rewrite
|
||||
<FilesMatch "^(bootstrap|Config|Log|Db|Http|Campuses|Phone|Validator|RateLimit|Turnstile|GeoIp|Geocode|Mailchimp|Sinch|LeadProcessor)\.php$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
<FilesMatch "\.(json|sqlite|sqlite-wal|sqlite-shm|log|lock|env)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header set X-Content-Type-Options "nosniff"
|
||||
</IfModule>
|
||||
|
||||
Options -Indexes
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
/**
|
||||
* CLI: export leads as CSV for staff follow-up.
|
||||
*
|
||||
* php api/cron/export-leads.php [--since=YYYY-MM-DD] > leads.csv
|
||||
*
|
||||
* Contains personal data — store the output securely and delete when done.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
require_once dirname(__DIR__) . '/lib/bootstrap.php';
|
||||
|
||||
$since = null;
|
||||
foreach ($argv as $arg) {
|
||||
if (str_starts_with($arg, '--since=')) {
|
||||
$since = substr($arg, 8);
|
||||
}
|
||||
}
|
||||
$sql = 'SELECT submission_id, created_at, first_name, last_name, email, phone_e164, campus_id, visit_date, service_time, adults, children, child_age_ranges, questions, email_consent, sms_consent, consent_version, consent_at, variant, attribution, mailchimp_status, sms_status FROM leads';
|
||||
$params = [];
|
||||
if ($since) {
|
||||
$sql .= ' WHERE created_at >= ?';
|
||||
$params[] = $since;
|
||||
}
|
||||
$sql .= ' ORDER BY created_at ASC';
|
||||
$st = Db::pdo()->prepare($sql);
|
||||
$st->execute($params);
|
||||
$out = fopen('php://stdout', 'w');
|
||||
$first = true;
|
||||
while ($row = $st->fetch()) {
|
||||
if ($first) {
|
||||
fputcsv($out, array_keys($row));
|
||||
$first = false;
|
||||
}
|
||||
fputcsv($out, $row);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
/**
|
||||
* Cron: retry failed Mailchimp / Sinch deliveries and purge expired leads.
|
||||
*
|
||||
* cPanel → Cron Jobs → every 5 minutes:
|
||||
* VISIT_PRIVATE_DIR=/home/USER/visit-private /usr/local/bin/php /home/USER/visit.waterschurch.org/api/cron/retry-integrations.php >> /home/USER/visit-private/logs/cron.log 2>&1
|
||||
*
|
||||
* CLI only — web requests are refused by the .htaccess in this folder AND by the guard below.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/lib/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/lib/LeadProcessor.php';
|
||||
|
||||
$lock = fopen(Config::dataDir() . '/retry.lock', 'c');
|
||||
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
fwrite(STDOUT, gmdate('c') . " retry: another run is in progress\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$result = LeadProcessor::retryDue(Config::int('RETRY_BATCH_SIZE', 25));
|
||||
$purged = LeadProcessor::purgeExpired();
|
||||
fwrite(STDOUT, sprintf("%s retry: checked=%d processed=%d purged=%d mode=%s\n", gmdate('c'), $result['checked'], $result['processed'], $purged, Config::mode()));
|
||||
flock($lock, LOCK_UN);
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
/** GET /api/geocode.php?zip=02763 — US ZIP centroid lookup for the manual campus search. */
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/lib/bootstrap.php';
|
||||
require_once __DIR__ . '/lib/Validator.php';
|
||||
require_once __DIR__ . '/lib/RateLimit.php';
|
||||
require_once __DIR__ . '/lib/Geocode.php';
|
||||
|
||||
Api::requireMethod('GET');
|
||||
|
||||
$ip = Api::clientIp();
|
||||
$rl = RateLimit::hit(RateLimit::ipBucket('geocode', $ip), 40, 600);
|
||||
if (!$rl['allowed']) {
|
||||
Api::json(['ok' => false, 'error' => 'Too many searches. Please wait a moment.'], 429, ['Retry-After' => (string)$rl['retryAfter']]);
|
||||
}
|
||||
|
||||
$zip = isset($_GET['zip']) ? preg_replace('/\D/', '', (string)$_GET['zip']) : '';
|
||||
$zip = substr((string)$zip, 0, 5);
|
||||
if (strlen($zip) !== 5) {
|
||||
Api::json(['ok' => false, 'error' => 'Enter a 5-digit ZIP code.'], 400);
|
||||
}
|
||||
|
||||
$loc = Geocode::zip($zip);
|
||||
if (!$loc) {
|
||||
Api::json(['ok' => false, 'error' => "We couldn't find that ZIP. Try another or pick a campus below."], 200);
|
||||
}
|
||||
Api::json(['ok' => true, 'location' => $loc], 200, ['Cache-Control' => 'public, max-age=86400']);
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /api/health.php — deployment self-check (no secrets or PII in the output).
|
||||
* Safe to leave enabled; reveals only booleans and version strings.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/lib/bootstrap.php';
|
||||
|
||||
Api::requireMethod('GET');
|
||||
|
||||
$checks = [];
|
||||
$checks['php'] = PHP_VERSION;
|
||||
$checks['extensions'] = [
|
||||
'curl' => extension_loaded('curl'),
|
||||
'pdo_sqlite' => extension_loaded('pdo_sqlite'),
|
||||
'pdo_mysql' => extension_loaded('pdo_mysql'),
|
||||
'json' => extension_loaded('json'),
|
||||
'mbstring' => extension_loaded('mbstring'),
|
||||
'openssl' => extension_loaded('openssl'),
|
||||
];
|
||||
$checks['private_dir_found'] = is_dir(Config::privateDir());
|
||||
$checks['env_file_found'] = is_readable(Config::privateDir() . '/.env');
|
||||
$checks['data_dir_writable'] = is_writable(Config::dataDir());
|
||||
$checks['logs_dir_writable'] = is_writable(Config::privateDir() . '/logs') || is_writable(Config::privateDir());
|
||||
$checks['integration_mode'] = Config::mode();
|
||||
$checks['mailchimp_configured'] = Config::get('MAILCHIMP_API_KEY') !== null && Config::get('MAILCHIMP_AUDIENCE_ID') !== null;
|
||||
$checks['sinch_configured'] = Config::get('SINCH_API_KEY') !== null && Config::get('SINCH_API_SECRET') !== null;
|
||||
$checks['geoip_provider'] = Config::get('GEOIP_PROVIDER', 'ipapi_co');
|
||||
$checks['geocode_provider'] = Config::get('GEOCODE_PROVIDER', 'zippopotam');
|
||||
$checks['turnstile_enabled'] = Config::get('TURNSTILE_SECRET_KEY') !== null;
|
||||
$checks['https'] = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||
|
||||
try {
|
||||
Db::pdo();
|
||||
$checks['database'] = 'ok (' . Db::pdo()->getAttribute(PDO::ATTR_DRIVER_NAME) . ')';
|
||||
} catch (Throwable $e) {
|
||||
$checks['database'] = 'error';
|
||||
Log::error('Health: database failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
try {
|
||||
$checks['campuses_loaded'] = count(Campuses::all());
|
||||
} catch (Throwable $e) {
|
||||
$checks['campuses_loaded'] = 0;
|
||||
}
|
||||
|
||||
$ok = $checks['private_dir_found'] && $checks['data_dir_writable'] && $checks['database'] !== 'error' && $checks['campuses_loaded'] > 0 && $checks['extensions']['curl'];
|
||||
Api::json(['ok' => $ok, 'checks' => $checks], $ok ? 200 : 500);
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Reads the shared campus configuration (config/campuses.json). The build
|
||||
* copies it to api/config/campuses.json; in local development we fall back to
|
||||
* the repo copy.
|
||||
*/
|
||||
final class Campuses
|
||||
{
|
||||
/** @var array<string,array<string,mixed>>|null */
|
||||
private static ?array $byId = null;
|
||||
|
||||
/** @return array<string,array<string,mixed>> */
|
||||
public static function all(): array
|
||||
{
|
||||
if (self::$byId !== null) {
|
||||
return self::$byId;
|
||||
}
|
||||
$candidates = [
|
||||
dirname(__DIR__) . '/config/campuses.json',
|
||||
dirname(__DIR__, 3) . '/config/campuses.json',
|
||||
];
|
||||
foreach ($candidates as $file) {
|
||||
if (is_readable($file)) {
|
||||
$data = json_decode((string)file_get_contents($file), true);
|
||||
if (is_array($data) && isset($data['campuses'])) {
|
||||
$out = [];
|
||||
foreach ($data['campuses'] as $c) {
|
||||
$out[$c['id']] = $c;
|
||||
}
|
||||
return self::$byId = $out;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('campuses.json not found');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function get(string $id): ?array
|
||||
{
|
||||
return self::all()[$id] ?? null;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public static function openIds(): array
|
||||
{
|
||||
return array_keys(array_filter(self::all(), static fn($c) => ($c['status'] ?? 'open') === 'open'));
|
||||
}
|
||||
|
||||
/** "Sunday 9:30 AM" style summary of the first service day. */
|
||||
public static function timesSummary(array $campus): string
|
||||
{
|
||||
$parts = [];
|
||||
foreach ($campus['serviceTimes'] ?? [] as $st) {
|
||||
$parts[] = $st['day'] . ' ' . implode(', ', $st['times']);
|
||||
}
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Configuration lives OUTSIDE the web root in a private directory containing a
|
||||
* `.env` file (see .env.example at the repo root for every key).
|
||||
*
|
||||
* The private directory is resolved in this order:
|
||||
* 1. VISIT_PRIVATE_DIR environment variable (set via `SetEnv` in .htaccess, or cPanel cron env)
|
||||
* 2. <parent of DOCUMENT_ROOT>/visit-private (e.g. /home/USER/visit-private)
|
||||
* 3. <repo>/private (local development only)
|
||||
*/
|
||||
final class Config
|
||||
{
|
||||
/** @var array<string,string> */
|
||||
private static array $values = [];
|
||||
private static ?string $privateDir = null;
|
||||
private static bool $loaded = false;
|
||||
|
||||
public static function load(): void
|
||||
{
|
||||
if (self::$loaded) {
|
||||
return;
|
||||
}
|
||||
self::$loaded = true;
|
||||
$dir = self::privateDir();
|
||||
$env = $dir . '/.env';
|
||||
if (is_readable($env)) {
|
||||
foreach (file($env, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$k, $v] = explode('=', $line, 2);
|
||||
$k = trim($k);
|
||||
$v = trim($v);
|
||||
if (str_starts_with($k, 'export ')) {
|
||||
$k = trim(substr($k, 7));
|
||||
}
|
||||
if ((str_starts_with($v, '"') && str_ends_with($v, '"')) || (str_starts_with($v, "'") && str_ends_with($v, "'"))) {
|
||||
$v = substr($v, 1, -1);
|
||||
}
|
||||
self::$values[$k] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function privateDir(): string
|
||||
{
|
||||
if (self::$privateDir !== null) {
|
||||
return self::$privateDir;
|
||||
}
|
||||
$candidates = [];
|
||||
$fromEnv = getenv('VISIT_PRIVATE_DIR') ?: ($_SERVER['VISIT_PRIVATE_DIR'] ?? ($_SERVER['REDIRECT_VISIT_PRIVATE_DIR'] ?? null));
|
||||
if ($fromEnv) {
|
||||
$candidates[] = $fromEnv;
|
||||
}
|
||||
if (!empty($_SERVER['DOCUMENT_ROOT'])) {
|
||||
$candidates[] = dirname(rtrim($_SERVER['DOCUMENT_ROOT'], '/')) . '/visit-private';
|
||||
}
|
||||
$candidates[] = dirname(__DIR__, 3) . '/private'; // repo/private (dev)
|
||||
foreach ($candidates as $c) {
|
||||
if (is_dir($c)) {
|
||||
return self::$privateDir = rtrim($c, '/');
|
||||
}
|
||||
}
|
||||
// Fall back to the first candidate so error messages point somewhere useful.
|
||||
return self::$privateDir = rtrim($candidates[0], '/');
|
||||
}
|
||||
|
||||
public static function get(string $key, ?string $default = null): ?string
|
||||
{
|
||||
$env = getenv($key);
|
||||
if ($env !== false && $env !== '') {
|
||||
return $env;
|
||||
}
|
||||
return (isset(self::$values[$key]) && self::$values[$key] !== '') ? self::$values[$key] : $default;
|
||||
}
|
||||
|
||||
public static function require(string $key): string
|
||||
{
|
||||
$v = self::get($key);
|
||||
if ($v === null) {
|
||||
throw new RuntimeException("Missing required configuration: $key");
|
||||
}
|
||||
return $v;
|
||||
}
|
||||
|
||||
public static function bool(string $key, bool $default = false): bool
|
||||
{
|
||||
$v = self::get($key);
|
||||
if ($v === null) {
|
||||
return $default;
|
||||
}
|
||||
return in_array(strtolower($v), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
public static function int(string $key, int $default): int
|
||||
{
|
||||
$v = self::get($key);
|
||||
return $v === null ? $default : (int)$v;
|
||||
}
|
||||
|
||||
/** "mock" (default when unset), "live". */
|
||||
public static function mode(): string
|
||||
{
|
||||
return strtolower(self::get('INTEGRATION_MODE', 'mock') ?? 'mock') === 'live' ? 'live' : 'mock';
|
||||
}
|
||||
|
||||
public static function isMock(): bool
|
||||
{
|
||||
return self::mode() === 'mock';
|
||||
}
|
||||
|
||||
public static function dataDir(): string
|
||||
{
|
||||
$d = self::get('DATA_DIR') ?: self::privateDir() . '/data';
|
||||
if (!is_dir($d)) {
|
||||
@mkdir($d, 0750, true);
|
||||
}
|
||||
return $d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Lead persistence. SQLite by default (available on virtually every cPanel PHP
|
||||
* build via pdo_sqlite), stored in the private data directory. Set
|
||||
* DATABASE_DSN / DATABASE_USER / DATABASE_PASSWORD to use cPanel MySQL instead.
|
||||
*
|
||||
* Leads are ALWAYS written here before any third-party call is made.
|
||||
*/
|
||||
final class Db
|
||||
{
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
public static function pdo(): PDO
|
||||
{
|
||||
if (self::$pdo instanceof PDO) {
|
||||
return self::$pdo;
|
||||
}
|
||||
$dsn = Config::get('DATABASE_DSN');
|
||||
if ($dsn) {
|
||||
$pdo = new PDO($dsn, Config::get('DATABASE_USER', ''), Config::get('DATABASE_PASSWORD', ''), [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_TIMEOUT => 5,
|
||||
]);
|
||||
} else {
|
||||
$file = Config::dataDir() . '/leads.sqlite';
|
||||
$isNew = !file_exists($file);
|
||||
$pdo = new PDO('sqlite:' . $file, null, null, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_TIMEOUT => 5,
|
||||
]);
|
||||
$pdo->exec('PRAGMA journal_mode=WAL');
|
||||
$pdo->exec('PRAGMA busy_timeout=5000');
|
||||
$pdo->exec('PRAGMA foreign_keys=ON');
|
||||
if ($isNew) {
|
||||
@chmod($file, 0640);
|
||||
}
|
||||
}
|
||||
self::$pdo = $pdo;
|
||||
self::migrate($pdo);
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
public static function isSqlite(): bool
|
||||
{
|
||||
return self::pdo()->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
|
||||
}
|
||||
|
||||
private static function migrate(PDO $pdo): void
|
||||
{
|
||||
$sqlite = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
|
||||
$text = $sqlite ? 'TEXT' : 'TEXT';
|
||||
$json = $sqlite ? 'TEXT' : 'JSON';
|
||||
$pk = $sqlite ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'BIGINT AUTO_INCREMENT PRIMARY KEY';
|
||||
$ts = $sqlite ? "TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))" : 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP';
|
||||
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS leads (
|
||||
id $pk,
|
||||
submission_id VARCHAR(36) NOT NULL UNIQUE,
|
||||
created_at $ts,
|
||||
updated_at $ts,
|
||||
first_name VARCHAR(80) NOT NULL,
|
||||
last_name VARCHAR(80) NULL,
|
||||
email VARCHAR(254) NOT NULL,
|
||||
phone_e164 VARCHAR(20) NULL,
|
||||
campus_id VARCHAR(40) NOT NULL,
|
||||
visit_date VARCHAR(10) NULL,
|
||||
service_time VARCHAR(80) NULL,
|
||||
adults SMALLINT NULL,
|
||||
children SMALLINT NULL,
|
||||
child_age_ranges VARCHAR(80) NULL,
|
||||
questions $text NULL,
|
||||
email_consent TINYINT NOT NULL DEFAULT 0,
|
||||
sms_consent TINYINT NOT NULL DEFAULT 0,
|
||||
consent_version VARCHAR(40) NOT NULL,
|
||||
consent_at VARCHAR(30) NOT NULL,
|
||||
consent_ip_hash VARCHAR(64) NULL,
|
||||
attribution $json NULL,
|
||||
variant VARCHAR(40) NULL,
|
||||
referrer $text NULL,
|
||||
landing_url $text NULL,
|
||||
user_agent VARCHAR(300) NULL,
|
||||
mailchimp_status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
mailchimp_detail $text NULL,
|
||||
mailchimp_attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
sms_status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
sms_detail $text NULL,
|
||||
sms_message_id VARCHAR(80) NULL,
|
||||
sms_attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
next_retry_at VARCHAR(30) NULL
|
||||
)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_leads_retry ON leads (next_retry_at)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_leads_created ON leads (created_at)");
|
||||
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS rate_limits (
|
||||
bucket VARCHAR(120) NOT NULL PRIMARY KEY,
|
||||
hits INT NOT NULL DEFAULT 0,
|
||||
window_start INT NOT NULL
|
||||
)");
|
||||
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS cache (
|
||||
cache_key VARCHAR(120) NOT NULL PRIMARY KEY,
|
||||
value $text NOT NULL,
|
||||
expires_at INT NOT NULL
|
||||
)");
|
||||
}
|
||||
|
||||
/** Simple cache helpers (used for geolocation lookups). */
|
||||
public static function cacheGet(string $key): ?string
|
||||
{
|
||||
$st = self::pdo()->prepare('SELECT value FROM cache WHERE cache_key = ? AND expires_at > ?');
|
||||
$st->execute([$key, time()]);
|
||||
$v = $st->fetchColumn();
|
||||
return $v === false ? null : (string)$v;
|
||||
}
|
||||
|
||||
public static function cacheSet(string $key, string $value, int $ttlSeconds): void
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (self::isSqlite()) {
|
||||
$st = $pdo->prepare('INSERT OR REPLACE INTO cache (cache_key, value, expires_at) VALUES (?, ?, ?)');
|
||||
} else {
|
||||
$st = $pdo->prepare('REPLACE INTO cache (cache_key, value, expires_at) VALUES (?, ?, ?)');
|
||||
}
|
||||
$st->execute([$key, $value, time() + $ttlSeconds]);
|
||||
if (random_int(1, 50) === 1) {
|
||||
$pdo->prepare('DELETE FROM cache WHERE expires_at < ?')->execute([time()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Privacy-conscious approximate IP geolocation for the "closest campus"
|
||||
* recommendation. Results are city-level, cached briefly by hashed IP, and
|
||||
* never stored with a lead.
|
||||
*
|
||||
* GEOIP_PROVIDER:
|
||||
* none – disabled (visitor always chooses manually)
|
||||
* header – trust a CDN header (Cloudflare: CF-IPCountry/CF-IPLatitude/CF-IPLongitude)
|
||||
* ipapi_co – https://ipapi.co (no key needed for low volume; GEOIP_API_KEY optional)
|
||||
* ipinfo – https://ipinfo.io (GEOIP_API_KEY required)
|
||||
* ip_api_com – http(s)://ip-api.com (GEOIP_API_KEY required for HTTPS/commercial use)
|
||||
*/
|
||||
final class GeoIp
|
||||
{
|
||||
/** @return array{lat:float,lng:float,city:?string,region:?string}|null */
|
||||
public static function locate(string $ip): ?array
|
||||
{
|
||||
$provider = strtolower(Config::get('GEOIP_PROVIDER', 'ipapi_co') ?? 'none');
|
||||
if ($provider === 'none' || self::isPrivate($ip)) {
|
||||
return null;
|
||||
}
|
||||
if ($provider === 'header') {
|
||||
return self::fromHeaders();
|
||||
}
|
||||
$cacheKey = 'geoip:' . substr(hash('sha256', $ip . (Config::get('HASH_SALT') ?? '')), 0, 40);
|
||||
$cached = Db::cacheGet($cacheKey);
|
||||
if ($cached !== null) {
|
||||
$c = json_decode($cached, true);
|
||||
return is_array($c) && $c ? $c : null;
|
||||
}
|
||||
$result = null;
|
||||
try {
|
||||
$result = match ($provider) {
|
||||
'ipapi_co' => self::ipapiCo($ip),
|
||||
'ipinfo' => self::ipinfo($ip),
|
||||
'ip_api_com' => self::ipApiCom($ip),
|
||||
default => null,
|
||||
};
|
||||
} catch (Throwable $e) {
|
||||
Log::warn('GeoIP provider failed', ['provider' => $provider, 'error' => $e->getMessage()]);
|
||||
}
|
||||
Db::cacheSet($cacheKey, json_encode($result ?? []), Config::int('GEOIP_CACHE_TTL', 3600));
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function fromHeaders(): ?array
|
||||
{
|
||||
$lat = $_SERVER['HTTP_CF_IPLATITUDE'] ?? null;
|
||||
$lng = $_SERVER['HTTP_CF_IPLONGITUDE'] ?? null;
|
||||
if ($lat === null || $lng === null) {
|
||||
return null;
|
||||
}
|
||||
return self::result((float)$lat, (float)$lng, $_SERVER['HTTP_CF_IPCITY'] ?? null, $_SERVER['HTTP_CF_REGION_CODE'] ?? null);
|
||||
}
|
||||
|
||||
private static function ipapiCo(string $ip): ?array
|
||||
{
|
||||
$key = Config::get('GEOIP_API_KEY');
|
||||
$base = rtrim(Config::get('GEOIP_BASE_URL_OVERRIDE') ?: 'https://ipapi.co', '/'); // override is for the test-suite only
|
||||
$url = "$base/" . rawurlencode($ip) . "/json/" . ($key ? "?key=" . rawurlencode($key) : '');
|
||||
$r = Http::request('GET', $url, null, ['Accept' => 'application/json'], 3);
|
||||
$j = $r['json'];
|
||||
if ($r['status'] !== 200 || !is_array($j) || !isset($j['latitude'], $j['longitude'])) {
|
||||
return null;
|
||||
}
|
||||
return self::result((float)$j['latitude'], (float)$j['longitude'], $j['city'] ?? null, $j['region_code'] ?? null);
|
||||
}
|
||||
|
||||
private static function ipinfo(string $ip): ?array
|
||||
{
|
||||
$key = Config::require('GEOIP_API_KEY');
|
||||
$r = Http::request('GET', "https://ipinfo.io/" . rawurlencode($ip) . "/json?token=" . rawurlencode($key), null, ['Accept' => 'application/json'], 3);
|
||||
$j = $r['json'];
|
||||
if ($r['status'] !== 200 || !is_array($j) || empty($j['loc'])) {
|
||||
return null;
|
||||
}
|
||||
[$lat, $lng] = array_map('floatval', explode(',', (string)$j['loc']) + [0, 0]);
|
||||
return self::result($lat, $lng, $j['city'] ?? null, $j['region'] ?? null);
|
||||
}
|
||||
|
||||
private static function ipApiCom(string $ip): ?array
|
||||
{
|
||||
$key = Config::get('GEOIP_API_KEY');
|
||||
$base = $key ? "https://pro.ip-api.com/json/" : "http://ip-api.com/json/";
|
||||
$r = Http::request('GET', $base . rawurlencode($ip) . "?fields=status,lat,lon,city,region" . ($key ? "&key=" . rawurlencode($key) : ''), null, [], 3);
|
||||
$j = $r['json'];
|
||||
if ($r['status'] !== 200 || !is_array($j) || ($j['status'] ?? '') !== 'success') {
|
||||
return null;
|
||||
}
|
||||
return self::result((float)$j['lat'], (float)$j['lon'], $j['city'] ?? null, $j['region'] ?? null);
|
||||
}
|
||||
|
||||
private static function result(float $lat, float $lng, ?string $city, ?string $region): ?array
|
||||
{
|
||||
if ($lat === 0.0 && $lng === 0.0 || abs($lat) > 90 || abs($lng) > 180) {
|
||||
return null;
|
||||
}
|
||||
// Round to ~1 km so we never hold anything close to a precise location.
|
||||
return ['lat' => round($lat, 2), 'lng' => round($lng, 2), 'city' => $city ? Validator::clean($city, 60) : null, 'region' => $region ? Validator::clean($region, 20) : null];
|
||||
}
|
||||
|
||||
public static function isPrivate(string $ip): bool
|
||||
{
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* US ZIP → coordinates for the manual campus search.
|
||||
*
|
||||
* GEOCODE_PROVIDER:
|
||||
* zippopotam – https://api.zippopotam.us (free, no key) [default]
|
||||
* nominatim – OpenStreetMap Nominatim (free, 1 req/s, requires identifying UA)
|
||||
* google – Google Geocoding API (GEOCODE_API_KEY required)
|
||||
*/
|
||||
final class Geocode
|
||||
{
|
||||
/** @return array{lat:float,lng:float,city:?string,state:?string}|null */
|
||||
public static function zip(string $zip): ?array
|
||||
{
|
||||
if (!preg_match('/^\d{5}$/', $zip)) {
|
||||
return null;
|
||||
}
|
||||
$cacheKey = 'zip:' . $zip;
|
||||
$cached = Db::cacheGet($cacheKey);
|
||||
if ($cached !== null) {
|
||||
$c = json_decode($cached, true);
|
||||
return is_array($c) && $c ? $c : null;
|
||||
}
|
||||
$provider = strtolower(Config::get('GEOCODE_PROVIDER', 'zippopotam') ?? 'zippopotam');
|
||||
$result = null;
|
||||
try {
|
||||
$result = match ($provider) {
|
||||
'nominatim' => self::nominatim($zip),
|
||||
'google' => self::google($zip),
|
||||
default => self::zippopotam($zip),
|
||||
};
|
||||
} catch (Throwable $e) {
|
||||
Log::warn('Geocode provider failed', ['provider' => $provider, 'error' => $e->getMessage()]);
|
||||
}
|
||||
// ZIP centroids do not change: cache for 30 days (failures for 10 minutes).
|
||||
Db::cacheSet($cacheKey, json_encode($result ?? []), $result ? 30 * 86400 : 600);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function zippopotam(string $zip): ?array
|
||||
{
|
||||
$base = rtrim(Config::get('GEOCODE_BASE_URL_OVERRIDE') ?: 'https://api.zippopotam.us', '/'); // override is for the test-suite only
|
||||
$r = Http::request('GET', "$base/us/$zip", null, ['Accept' => 'application/json'], 6);
|
||||
$j = $r['json'];
|
||||
if ($r['status'] !== 200 || !is_array($j) || empty($j['places'][0])) {
|
||||
return null;
|
||||
}
|
||||
$p = $j['places'][0];
|
||||
return self::result((float)$p['latitude'], (float)$p['longitude'], $p['place name'] ?? null, $p['state abbreviation'] ?? null);
|
||||
}
|
||||
|
||||
private static function nominatim(string $zip): ?array
|
||||
{
|
||||
$r = Http::request('GET', "https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&countrycodes=us&postalcode=$zip", null, ['Accept' => 'application/json'], 6);
|
||||
$j = $r['json'];
|
||||
if ($r['status'] !== 200 || !is_array($j) || empty($j[0]['lat'])) {
|
||||
return null;
|
||||
}
|
||||
return self::result((float)$j[0]['lat'], (float)$j[0]['lon'], null, null);
|
||||
}
|
||||
|
||||
private static function google(string $zip): ?array
|
||||
{
|
||||
$key = Config::require('GEOCODE_API_KEY');
|
||||
$r = Http::request('GET', "https://maps.googleapis.com/maps/api/geocode/json?components=postal_code:$zip|country:US&key=" . rawurlencode($key), null, [], 6);
|
||||
$j = $r['json'];
|
||||
if ($r['status'] !== 200 || !is_array($j) || ($j['status'] ?? '') !== 'OK') {
|
||||
return null;
|
||||
}
|
||||
$loc = $j['results'][0]['geometry']['location'] ?? null;
|
||||
if (!$loc) {
|
||||
return null;
|
||||
}
|
||||
$city = null;
|
||||
$state = null;
|
||||
foreach ($j['results'][0]['address_components'] ?? [] as $c) {
|
||||
if (in_array('locality', $c['types'] ?? [], true)) {
|
||||
$city = $c['long_name'];
|
||||
}
|
||||
if (in_array('administrative_area_level_1', $c['types'] ?? [], true)) {
|
||||
$state = $c['short_name'];
|
||||
}
|
||||
}
|
||||
return self::result((float)$loc['lat'], (float)$loc['lng'], $city, $state);
|
||||
}
|
||||
|
||||
private static function result(float $lat, float $lng, ?string $city, ?string $state): ?array
|
||||
{
|
||||
if (($lat === 0.0 && $lng === 0.0) || abs($lat) > 90 || abs($lng) > 180) {
|
||||
return null;
|
||||
}
|
||||
return ['lat' => round($lat, 4), 'lng' => round($lng, 4), 'city' => $city ? Validator::clean($city, 60) : null, 'state' => $state ? Validator::clean($state, 20) : null];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Small cURL wrapper with hard timeouts, used by every provider adapter. */
|
||||
final class Http
|
||||
{
|
||||
/**
|
||||
* @param array<string,string> $headers
|
||||
* @return array{status:int, body:string, json:mixed, error:?string}
|
||||
*/
|
||||
public static function request(string $method, string $url, ?string $body = null, array $headers = [], int $timeout = 8): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
$hdrs = [];
|
||||
foreach ($headers as $k => $v) {
|
||||
$hdrs[] = "$k: $v";
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_CONNECTTIMEOUT => min(5, $timeout),
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_HTTPHEADER => $hdrs,
|
||||
CURLOPT_USERAGENT => 'visit.waterschurch.org/1.0 (+https://visit.waterschurch.org)',
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
$out = curl_exec($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$err = curl_error($ch) ?: null;
|
||||
$text = is_string($out) ? $out : '';
|
||||
$json = null;
|
||||
if ($text !== '') {
|
||||
$decoded = json_decode($text, true);
|
||||
$json = json_last_error() === JSON_ERROR_NONE ? $decoded : null;
|
||||
}
|
||||
return ['status' => $status, 'body' => $text, 'json' => $json, 'error' => $err];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @param array<string,string> $headers
|
||||
* @return array{status:int, body:string, json:mixed, error:?string}
|
||||
*/
|
||||
public static function json(string $method, string $url, ?array $payload, array $headers = [], int $timeout = 8): array
|
||||
{
|
||||
$headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json'] + $headers;
|
||||
return self::request($method, $url, $payload === null ? null : json_encode($payload, JSON_UNESCAPED_SLASHES), $headers, $timeout);
|
||||
}
|
||||
|
||||
/** True for statuses/transport errors that are worth retrying later. */
|
||||
public static function isTransient(int $status, ?string $error): bool
|
||||
{
|
||||
return $error !== null || $status === 0 || $status === 408 || $status === 429 || $status >= 500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/Mailchimp.php';
|
||||
require_once __DIR__ . '/Sinch.php';
|
||||
|
||||
/**
|
||||
* Persists a lead FIRST, then runs integrations, recording each outcome so a
|
||||
* partial failure never loses the lead and can be retried by cron.
|
||||
*/
|
||||
final class LeadProcessor
|
||||
{
|
||||
public const MAX_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $d Validated data from Validator::planVisit
|
||||
* @return array{lead:array<string,mixed>, duplicate:bool}
|
||||
*/
|
||||
public static function persist(array $d, string $ip, string $userAgent): array
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$existing = self::find($d['submission_id']);
|
||||
if ($existing) {
|
||||
return ['lead' => $existing, 'duplicate' => true];
|
||||
}
|
||||
$now = gmdate('Y-m-d\TH:i:s\Z');
|
||||
$st = $pdo->prepare('INSERT INTO leads (
|
||||
submission_id, created_at, updated_at, first_name, last_name, email, phone_e164, campus_id, visit_date, service_time,
|
||||
adults, children, child_age_ranges, questions, email_consent, sms_consent, consent_version, consent_at, consent_ip_hash,
|
||||
attribution, variant, referrer, landing_url, user_agent, mailchimp_status, sms_status
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)');
|
||||
$attr = $d['attribution'];
|
||||
try {
|
||||
$st->execute([
|
||||
$d['submission_id'], $now, $now, $d['first_name'], $d['last_name'], $d['email'], $d['phone_e164'], $d['campus_id'], $d['visit_date'], $d['service_time'],
|
||||
$d['adults'], $d['children'], $d['child_age_ranges'], $d['questions'], (int)$d['email_consent'], (int)$d['sms_consent'], $d['consent_version'], $now,
|
||||
hash('sha256', $ip . '|' . (Config::get('HASH_SALT') ?? 'visit')),
|
||||
json_encode($attr, JSON_UNESCAPED_SLASHES), $attr['variant'] ?? null, $attr['referrer'] ?? null, $attr['landing_url'] ?? null, mb_substr($userAgent, 0, 300),
|
||||
$d['email_consent'] ? 'pending' : 'skipped', $d['sms_consent'] ? 'pending' : 'skipped',
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
// Unique constraint race: another request with the same id got there first.
|
||||
if (str_contains($e->getMessage(), 'UNIQUE') || str_contains($e->getMessage(), 'Duplicate')) {
|
||||
$row = self::find($d['submission_id']);
|
||||
if ($row) {
|
||||
return ['lead' => $row, 'duplicate' => true];
|
||||
}
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
$lead = self::find($d['submission_id']);
|
||||
if (!$lead) {
|
||||
throw new RuntimeException('Lead insert failed');
|
||||
}
|
||||
return ['lead' => $lead, 'duplicate' => false];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function find(string $submissionId): ?array
|
||||
{
|
||||
$st = Db::pdo()->prepare('SELECT * FROM leads WHERE submission_id = ?');
|
||||
$st->execute([$submissionId]);
|
||||
$row = $st->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs Mailchimp then Sinch for a stored lead. Each step is guarded by a
|
||||
* status transition so concurrent requests/cron cannot double-send.
|
||||
*
|
||||
* @param array<string,mixed> $lead
|
||||
* @return array{email:string, sms:string}
|
||||
*/
|
||||
public static function runIntegrations(array $lead): array
|
||||
{
|
||||
$campus = Campuses::get($lead['campus_id']) ?? ['id' => $lead['campus_id'], 'name' => $lead['campus_id'], 'address' => ['line1' => '', 'city' => ''], 'serviceTimes' => []];
|
||||
$out = ['email' => $lead['mailchimp_status'], 'sms' => $lead['sms_status']];
|
||||
|
||||
if (self::claim($lead['id'], 'mailchimp')) {
|
||||
try {
|
||||
$r = Mailchimp::upsertVisitor($lead, $campus);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Mailchimp adapter threw', ['error' => $e->getMessage()]);
|
||||
$r = ['status' => 'failed', 'detail' => 'exception: ' . $e->getMessage(), 'transient' => true];
|
||||
}
|
||||
$out['email'] = self::record($lead['id'], 'mailchimp', $r['status'], $r['detail'] ?? null, $r['transient'] ?? false);
|
||||
}
|
||||
|
||||
if (self::claim($lead['id'], 'sms')) {
|
||||
try {
|
||||
$r = Sinch::sendVisitConfirmation($lead, $campus);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Sinch adapter threw', ['error' => $e->getMessage()]);
|
||||
$r = ['status' => 'failed', 'detail' => 'exception: ' . $e->getMessage(), 'message_id' => null, 'transient' => true];
|
||||
}
|
||||
$out['sms'] = self::record($lead['id'], 'sms', $r['status'], $r['detail'] ?? null, $r['transient'] ?? false, $r['message_id'] ?? null);
|
||||
if (in_array($r['status'], ['sent', 'mock'], true)) {
|
||||
try {
|
||||
Sinch::sendInternalLeadNotification($lead, $campus);
|
||||
} catch (Throwable $e) {
|
||||
Log::warn('Internal notify failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Atomically move pending/retry → sending. Returns false if someone else owns it. */
|
||||
private static function claim(int $id, string $which): bool
|
||||
{
|
||||
$col = $which === 'sms' ? 'sms_status' : 'mailchimp_status';
|
||||
$att = $which === 'sms' ? 'sms_attempts' : 'mailchimp_attempts';
|
||||
$st = Db::pdo()->prepare("UPDATE leads SET $col = 'sending', $att = $att + 1, updated_at = ? WHERE id = ? AND $col IN ('pending','retry')");
|
||||
$st->execute([gmdate('Y-m-d\TH:i:s\Z'), $id]);
|
||||
return $st->rowCount() === 1;
|
||||
}
|
||||
|
||||
/** Persists the outcome and returns the final stored status ('sent', 'mock', 'skipped', 'failed' or 'retry'). */
|
||||
private static function record(int $id, string $which, string $status, ?string $detail, bool $transient, ?string $messageId = null): string
|
||||
{
|
||||
$col = $which === 'sms' ? 'sms_status' : 'mailchimp_status';
|
||||
$det = $which === 'sms' ? 'sms_detail' : 'mailchimp_detail';
|
||||
$att = $which === 'sms' ? 'sms_attempts' : 'mailchimp_attempts';
|
||||
$pdo = Db::pdo();
|
||||
$attempts = (int)$pdo->query("SELECT $att FROM leads WHERE id = $id")->fetchColumn();
|
||||
$final = $status;
|
||||
$nextRetry = null;
|
||||
if ($status === 'failed' && $transient && $attempts < self::MAX_ATTEMPTS) {
|
||||
$final = 'retry';
|
||||
$nextRetry = gmdate('Y-m-d\TH:i:s\Z', time() + self::backoffSeconds($attempts));
|
||||
}
|
||||
$sql = "UPDATE leads SET $col = ?, $det = ?, updated_at = ?" . ($which === 'sms' ? ', sms_message_id = COALESCE(?, sms_message_id)' : '') . ', next_retry_at = ? WHERE id = ?';
|
||||
$params = [$final, $detail ? mb_substr($detail, 0, 500) : null, gmdate('Y-m-d\TH:i:s\Z')];
|
||||
if ($which === 'sms') {
|
||||
$params[] = $messageId;
|
||||
}
|
||||
$params[] = $nextRetry;
|
||||
$params[] = $id;
|
||||
$pdo->prepare($sql)->execute($params);
|
||||
// Keep the earliest pending retry across both integrations
|
||||
$pdo->prepare("UPDATE leads SET next_retry_at = (SELECT MIN(x) FROM (SELECT next_retry_at AS x FROM leads WHERE id = ?)) WHERE id = ?")->execute([$id, $id]);
|
||||
Log::info("Integration $which → $final", ['lead' => $id, 'attempts' => $attempts]);
|
||||
return $final;
|
||||
}
|
||||
|
||||
public static function backoffSeconds(int $attempt): int
|
||||
{
|
||||
return min(6 * 3600, (int)(60 * (2 ** max(0, $attempt - 1)))); // 1m, 2m, 4m, 8m … capped at 6h
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron entry point: retry rows whose integrations are in 'retry' (or stuck in 'sending' for >15 min).
|
||||
* @return array{checked:int, processed:int}
|
||||
*/
|
||||
public static function retryDue(int $limit = 25): array
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$now = gmdate('Y-m-d\TH:i:s\Z');
|
||||
$stale = gmdate('Y-m-d\TH:i:s\Z', time() - 900);
|
||||
// Unstick crashed "sending" rows
|
||||
$pdo->prepare("UPDATE leads SET mailchimp_status = 'retry' WHERE mailchimp_status = 'sending' AND updated_at < ?")->execute([$stale]);
|
||||
$pdo->prepare("UPDATE leads SET sms_status = 'retry' WHERE sms_status = 'sending' AND updated_at < ?")->execute([$stale]);
|
||||
$st = $pdo->prepare("SELECT * FROM leads WHERE (mailchimp_status = 'retry' OR sms_status = 'retry') AND (next_retry_at IS NULL OR next_retry_at <= ?) ORDER BY created_at ASC LIMIT $limit");
|
||||
$st->execute([$now]);
|
||||
$rows = $st->fetchAll();
|
||||
$processed = 0;
|
||||
foreach ($rows as $row) {
|
||||
self::runIntegrations($row);
|
||||
$processed++;
|
||||
}
|
||||
return ['checked' => count($rows), 'processed' => $processed];
|
||||
}
|
||||
|
||||
/** Data minimisation: delete leads older than LEAD_RETENTION_DAYS (0 disables). */
|
||||
public static function purgeExpired(): int
|
||||
{
|
||||
$days = Config::int('LEAD_RETENTION_DAYS', 0);
|
||||
if ($days <= 0) {
|
||||
return 0;
|
||||
}
|
||||
$cutoff = gmdate('Y-m-d\TH:i:s\Z', time() - $days * 86400);
|
||||
$st = Db::pdo()->prepare("DELETE FROM leads WHERE created_at < ? AND mailchimp_status NOT IN ('pending','retry','sending') AND sms_status NOT IN ('pending','retry','sending')");
|
||||
$st->execute([$cutoff]);
|
||||
return $st->rowCount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Minimal structured logger. Writes JSON lines to a private log file and
|
||||
* redacts anything that looks like personal data or a secret.
|
||||
*/
|
||||
final class Log
|
||||
{
|
||||
private static string $file = '';
|
||||
private static int $level = 1;
|
||||
private const LEVELS = ['debug' => 0, 'info' => 1, 'warn' => 2, 'error' => 3];
|
||||
private const REDACT_KEYS = ['email', 'phone', 'firstname', 'first_name', 'lastname', 'last_name', 'authorization', 'api_key', 'apikey', 'secret', 'token', 'password', 'questions', 'destination_number', 'email_address'];
|
||||
|
||||
public static function init(string $file, ?string $level = 'info'): void
|
||||
{
|
||||
self::$file = $file;
|
||||
self::$level = self::LEVELS[strtolower($level ?? 'info')] ?? 1;
|
||||
$dir = dirname($file);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0750, true);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $ctx */
|
||||
public static function debug(string $msg, array $ctx = []): void { self::write('debug', $msg, $ctx); }
|
||||
/** @param array<string,mixed> $ctx */
|
||||
public static function info(string $msg, array $ctx = []): void { self::write('info', $msg, $ctx); }
|
||||
/** @param array<string,mixed> $ctx */
|
||||
public static function warn(string $msg, array $ctx = []): void { self::write('warn', $msg, $ctx); }
|
||||
/** @param array<string,mixed> $ctx */
|
||||
public static function error(string $msg, array $ctx = []): void { self::write('error', $msg, $ctx); }
|
||||
|
||||
/** @param array<string,mixed> $ctx */
|
||||
private static function write(string $level, string $msg, array $ctx): void
|
||||
{
|
||||
if ((self::LEVELS[$level] ?? 1) < self::$level) {
|
||||
return;
|
||||
}
|
||||
$line = json_encode([
|
||||
'ts' => gmdate('c'),
|
||||
'level' => $level,
|
||||
'msg' => $msg,
|
||||
'ctx' => self::redact($ctx),
|
||||
], JSON_UNESCAPED_SLASHES) . "\n";
|
||||
if (self::$file !== '' && (is_writable(dirname(self::$file)) || is_writable(self::$file))) {
|
||||
@file_put_contents(self::$file, $line, FILE_APPEND | LOCK_EX);
|
||||
} else {
|
||||
error_log(rtrim($line));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param mixed $value */
|
||||
public static function redact($value, string $key = ''): mixed
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$out = [];
|
||||
foreach ($value as $k => $v) {
|
||||
$out[$k] = self::redact($v, (string)$k);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
if (is_string($value)) {
|
||||
if (in_array(strtolower($key), self::REDACT_KEYS, true)) {
|
||||
return self::mask($value);
|
||||
}
|
||||
// Inline redaction of emails, phone numbers and bearer-like tokens inside free text
|
||||
$value = preg_replace('/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i', '[email]', $value) ?? $value;
|
||||
$value = preg_replace('/\+?\d[\d\s().-]{8,}\d/', '[phone]', $value) ?? $value;
|
||||
return preg_replace('/(Basic|Bearer)\s+[A-Za-z0-9+\/=._-]{8,}/', '$1 [redacted]', $value) ?? $value;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function mask(string $v): string
|
||||
{
|
||||
$len = strlen($v);
|
||||
if ($len === 0) {
|
||||
return '';
|
||||
}
|
||||
if ($len <= 4) {
|
||||
return str_repeat('*', $len);
|
||||
}
|
||||
return substr($v, 0, 2) . str_repeat('*', max(1, $len - 4)) . substr($v, -2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Mailchimp Marketing API v3 adapter (server-only).
|
||||
*
|
||||
* Required config: MAILCHIMP_API_KEY, MAILCHIMP_SERVER_PREFIX (e.g. us21), MAILCHIMP_AUDIENCE_ID
|
||||
* Optional: MAILCHIMP_JOURNEY_ID + MAILCHIMP_JOURNEY_STEP_ID (Customer Journey API trigger),
|
||||
* MAILCHIMP_TAG_PREFIX (default "Visit:" → tags like "Visit:Norwood", matching the
|
||||
* church's existing convention), MAILCHIMP_DOUBLE_OPTIN (default false)
|
||||
*
|
||||
* Idempotency: contacts are keyed by the MD5 of the lower-cased email (Mailchimp's
|
||||
* subscriber hash) and upserted with PUT, so retries never create duplicates.
|
||||
*/
|
||||
final class Mailchimp
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $lead Row from the leads table
|
||||
* @param array<string,mixed> $campus
|
||||
* @return array{status:string, detail:?string, transient:bool}
|
||||
*/
|
||||
public static function upsertVisitor(array $lead, array $campus): array
|
||||
{
|
||||
if (!(int)$lead['email_consent']) {
|
||||
return ['status' => 'skipped', 'detail' => 'no email consent', 'transient' => false];
|
||||
}
|
||||
$payload = self::buildMemberPayload($lead, $campus);
|
||||
$tags = self::buildTags($lead, $campus);
|
||||
|
||||
if (Config::isMock()) {
|
||||
Log::info('[mock] Mailchimp upsert', ['tags' => $tags, 'merge_fields' => array_keys($payload['merge_fields'])]);
|
||||
return ['status' => 'mock', 'detail' => 'mock mode: ' . implode(',', $tags), 'transient' => false];
|
||||
}
|
||||
|
||||
$apiKey = Config::require('MAILCHIMP_API_KEY');
|
||||
$dc = Config::get('MAILCHIMP_SERVER_PREFIX') ?: (str_contains($apiKey, '-') ? substr($apiKey, strrpos($apiKey, '-') + 1) : null);
|
||||
if (!$dc) {
|
||||
throw new RuntimeException('MAILCHIMP_SERVER_PREFIX missing');
|
||||
}
|
||||
$list = Config::require('MAILCHIMP_AUDIENCE_ID');
|
||||
$hash = md5(strtolower($lead['email']));
|
||||
$base = Config::get('MAILCHIMP_BASE_URL_OVERRIDE') ?: "https://$dc.api.mailchimp.com/3.0"; // override is for the test-suite only
|
||||
$auth = ['Authorization' => 'Basic ' . base64_encode("anystring:$apiKey")];
|
||||
|
||||
// 1) Upsert the member (idempotent)
|
||||
$r = Http::json('PUT', "$base/lists/$list/members/$hash?skip_merge_validation=true", $payload, $auth, 10);
|
||||
if ($r['status'] < 200 || $r['status'] >= 300) {
|
||||
$detail = is_array($r['json']) ? ($r['json']['title'] ?? '') . ': ' . ($r['json']['detail'] ?? '') : ($r['error'] ?? "HTTP {$r['status']}");
|
||||
$transient = Http::isTransient($r['status'], $r['error']);
|
||||
Log::warn('Mailchimp upsert failed', ['status' => $r['status'], 'detail' => $detail]);
|
||||
return ['status' => 'failed', 'detail' => substr($detail, 0, 500), 'transient' => $transient];
|
||||
}
|
||||
|
||||
// 2) Apply tags (separate endpoint; PUT on members accepts tags only on create)
|
||||
$tr = Http::json('POST', "$base/lists/$list/members/$hash/tags", [
|
||||
'tags' => array_map(static fn($t) => ['name' => $t, 'status' => 'active'], $tags),
|
||||
], $auth, 10);
|
||||
if ($tr['status'] < 200 || $tr['status'] >= 300) {
|
||||
Log::warn('Mailchimp tag update failed (member saved)', ['status' => $tr['status']]);
|
||||
}
|
||||
|
||||
// 3) Optionally trigger a Customer Journey step (API-triggered journeys)
|
||||
$journey = Config::get('MAILCHIMP_JOURNEY_ID');
|
||||
$step = Config::get('MAILCHIMP_JOURNEY_STEP_ID');
|
||||
$journeyNote = '';
|
||||
if ($journey && $step) {
|
||||
$jr = Http::json('POST', "$base/customer-journeys/journeys/$journey/steps/$step/actions/trigger", ['email_address' => $lead['email']], $auth, 10);
|
||||
$journeyNote = ($jr['status'] >= 200 && $jr['status'] < 300) ? '; journey triggered' : "; journey trigger HTTP {$jr['status']}";
|
||||
if ($jr['status'] < 200 || $jr['status'] >= 300) {
|
||||
Log::warn('Mailchimp journey trigger failed', ['status' => $jr['status'], 'body' => substr($jr['body'], 0, 300)]);
|
||||
}
|
||||
}
|
||||
|
||||
$status = is_array($r['json']) ? ($r['json']['status'] ?? 'unknown') : 'unknown';
|
||||
return ['status' => 'sent', 'detail' => "member status: $status" . $journeyNote, 'transient' => false];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $lead
|
||||
* @param array<string,mixed> $campus
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function buildMemberPayload(array $lead, array $campus): array
|
||||
{
|
||||
$attr = is_string($lead['attribution'] ?? null) ? (json_decode($lead['attribution'], true) ?: []) : ($lead['attribution'] ?? []);
|
||||
$doubleOptIn = Config::bool('MAILCHIMP_DOUBLE_OPTIN', false);
|
||||
$merge = [
|
||||
'FNAME' => $lead['first_name'],
|
||||
'LNAME' => $lead['last_name'] ?? '',
|
||||
'CAMPUS' => $campus['name'],
|
||||
'VISITDATE' => $lead['visit_date'] ?? '',
|
||||
'SERVICE' => $lead['service_time'] ?? '',
|
||||
'ADULTS' => $lead['adults'] ?? '',
|
||||
'KIDS' => $lead['children'] ?? '',
|
||||
'KIDAGES' => $lead['child_age_ranges'] ?? '',
|
||||
'QUESTIONS' => mb_substr((string)($lead['questions'] ?? ''), 0, 255),
|
||||
'UTMSOURCE' => $attr['utm_source'] ?? '',
|
||||
'UTMCAMP' => $attr['utm_campaign'] ?? '',
|
||||
'LPVARIANT' => $attr['variant'] ?? '',
|
||||
'SUBID' => $lead['submission_id'],
|
||||
];
|
||||
if (!empty($lead['phone_e164'])) {
|
||||
$merge['PHONE'] = $lead['phone_e164'];
|
||||
}
|
||||
// Remove empties so we never overwrite existing values with blanks.
|
||||
$merge = array_filter($merge, static fn($v) => $v !== '' && $v !== null);
|
||||
|
||||
return [
|
||||
'email_address' => $lead['email'],
|
||||
'status_if_new' => $doubleOptIn ? 'pending' : 'subscribed',
|
||||
'email_type' => 'html',
|
||||
'merge_fields' => $merge,
|
||||
'marketing_permissions' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $lead
|
||||
* @param array<string,mixed> $campus
|
||||
* @return string[]
|
||||
*/
|
||||
public static function buildTags(array $lead, array $campus): array
|
||||
{
|
||||
$prefix = Config::get('MAILCHIMP_TAG_PREFIX', 'Visit:') ?? 'Visit:';
|
||||
$attr = is_string($lead['attribution'] ?? null) ? (json_decode($lead['attribution'], true) ?: []) : ($lead['attribution'] ?? []);
|
||||
// Campus tag first: "Visit:Norwood" is what the church's Mailchimp journeys trigger on.
|
||||
$tags = [
|
||||
$prefix . $campus['name'],
|
||||
$prefix . 'First-Time Visitor',
|
||||
$prefix . 'Source visit.waterschurch.org',
|
||||
];
|
||||
$platform = self::platform($attr);
|
||||
$tags[] = $prefix . 'Platform ' . $platform;
|
||||
if (!empty($attr['utm_campaign'])) {
|
||||
$tags[] = $prefix . 'Campaign ' . mb_substr($attr['utm_campaign'], 0, 60);
|
||||
}
|
||||
if (!empty($lead['visit_date'])) {
|
||||
$tags[] = $prefix . 'Visit ' . $lead['visit_date'];
|
||||
} elseif (!empty($lead['service_time'])) {
|
||||
$tags[] = $prefix . 'Service ' . mb_substr($lead['service_time'], 0, 40);
|
||||
}
|
||||
if ((int)($lead['children'] ?? 0) > 0) {
|
||||
$tags[] = $prefix . 'Has Kids';
|
||||
}
|
||||
if ((int)$lead['sms_consent']) {
|
||||
$tags[] = $prefix . 'SMS Opt-In';
|
||||
}
|
||||
return array_values(array_unique(array_map(static fn($t) => mb_substr($t, 0, 100), $tags)));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $attr */
|
||||
public static function platform(array $attr): string
|
||||
{
|
||||
$src = strtolower((string)($attr['utm_source'] ?? ''));
|
||||
if (!empty($attr['gclid']) || !empty($attr['gbraid']) || !empty($attr['wbraid']) || preg_match('/google|adwords/', $src)) {
|
||||
return 'Google';
|
||||
}
|
||||
if (preg_match('/instagram|^ig$/', $src)) {
|
||||
return 'Instagram';
|
||||
}
|
||||
if (!empty($attr['fbclid']) || preg_match('/facebook|^fb$|meta/', $src)) {
|
||||
return 'Facebook';
|
||||
}
|
||||
if (!empty($attr['ttclid']) || str_contains($src, 'tiktok')) {
|
||||
return 'TikTok';
|
||||
}
|
||||
if (!empty($attr['twclid']) || preg_match('/twitter|^x$|x\.com/', $src)) {
|
||||
return 'X';
|
||||
}
|
||||
return $src !== '' ? ucfirst($src) : 'Direct';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** North American Numbering Plan normalization to E.164 (+1NXXNXXXXXX). */
|
||||
final class Phone
|
||||
{
|
||||
public static function normalizeNanp(string $input): ?string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $input) ?? '';
|
||||
if (strlen($digits) === 11 && $digits[0] === '1') {
|
||||
$digits = substr($digits, 1);
|
||||
}
|
||||
if (strlen($digits) !== 10) {
|
||||
return null;
|
||||
}
|
||||
$area = substr($digits, 0, 3);
|
||||
$exchange = substr($digits, 3, 3);
|
||||
if (in_array($area[0], ['0', '1'], true) || in_array($exchange[0], ['0', '1'], true)) {
|
||||
return null;
|
||||
}
|
||||
if ($area[1] === '1' && $area[2] === '1') {
|
||||
return null; // N11 service codes
|
||||
}
|
||||
return '+1' . $digits;
|
||||
}
|
||||
|
||||
public static function isE164(string $v): bool
|
||||
{
|
||||
return (bool)preg_match('/^\+[1-9]\d{6,14}$/', $v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Fixed-window rate limiter backed by the database (works on shared hosting without Redis). */
|
||||
final class RateLimit
|
||||
{
|
||||
/**
|
||||
* @return array{allowed:bool, retryAfter:int}
|
||||
*/
|
||||
public static function hit(string $bucket, int $limit, int $windowSeconds): array
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$now = time();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$st = $pdo->prepare('SELECT hits, window_start FROM rate_limits WHERE bucket = ?');
|
||||
$st->execute([$bucket]);
|
||||
$row = $st->fetch();
|
||||
if (!$row || ($now - (int)$row['window_start']) >= $windowSeconds) {
|
||||
if (Db::isSqlite()) {
|
||||
$pdo->prepare('INSERT OR REPLACE INTO rate_limits (bucket, hits, window_start) VALUES (?, 1, ?)')->execute([$bucket, $now]);
|
||||
} else {
|
||||
$pdo->prepare('REPLACE INTO rate_limits (bucket, hits, window_start) VALUES (?, 1, ?)')->execute([$bucket, $now]);
|
||||
}
|
||||
$pdo->commit();
|
||||
return ['allowed' => true, 'retryAfter' => 0];
|
||||
}
|
||||
$hits = (int)$row['hits'] + 1;
|
||||
$pdo->prepare('UPDATE rate_limits SET hits = ? WHERE bucket = ?')->execute([$hits, $bucket]);
|
||||
$pdo->commit();
|
||||
if ($hits > $limit) {
|
||||
return ['allowed' => false, 'retryAfter' => max(1, $windowSeconds - ($now - (int)$row['window_start']))];
|
||||
}
|
||||
return ['allowed' => true, 'retryAfter' => 0];
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
Log::warn('Rate limit storage failed; allowing request', ['error' => $e->getMessage()]);
|
||||
return ['allowed' => true, 'retryAfter' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable, non-reversible bucket key for an IP. */
|
||||
public static function ipBucket(string $prefix, string $ip): string
|
||||
{
|
||||
return $prefix . ':' . substr(hash('sha256', $ip . '|' . (Config::get('HASH_SALT') ?? 'visit')), 0, 32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Sinch Engage (formerly MessageMedia) Messages API adapter (server-only).
|
||||
*
|
||||
* Verified against the Sinch Engage OpenAPI spec (github.com/messagemedia/sinchengage-developer):
|
||||
* POST {SINCH_API_BASE}/v1/messages
|
||||
* Authorization: Basic base64(api_key:api_secret)
|
||||
* Account: <sub-account id> (optional; lets a parent key send as a campus sub-account)
|
||||
* { "messages": [ { "content", "destination_number", "source_number", "format": "SMS",
|
||||
* "delivery_report": true, "metadata": {...}, "message_expiry_timestamp" } ] }
|
||||
*
|
||||
* Regional hosts: https://us.app.api.sinch.com (US), https://eu.app.api.sinch.com, https://au.app.api.sinch.com
|
||||
*
|
||||
* Config: SINCH_API_BASE, SINCH_API_KEY, SINCH_API_SECRET, SINCH_DEFAULT_SOURCE_NUMBER,
|
||||
* SINCH_USE_CAMPUS_SUBACCOUNTS (default true), SINCH_INTERNAL_NOTIFY_NUMBER (optional)
|
||||
*
|
||||
* Idempotency: a lead is only sent once. The DB row is marked "sending" before the
|
||||
* request and updated with the provider message_id after; retries skip rows that are
|
||||
* not in a retryable state.
|
||||
*/
|
||||
final class Sinch
|
||||
{
|
||||
public const TEMPLATE_DEFAULT = "Hi {{first_name}} — we're excited that you're considering Waters Church {{campus_name}}. Your visit details: {{service_or_date}}. Have a question? Reply to this message. Reply STOP to opt out.";
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $lead
|
||||
* @param array<string,mixed> $campus
|
||||
* @return array{status:string, detail:?string, message_id:?string, transient:bool}
|
||||
*/
|
||||
public static function sendVisitConfirmation(array $lead, array $campus): array
|
||||
{
|
||||
if (!(int)$lead['sms_consent']) {
|
||||
return ['status' => 'skipped', 'detail' => 'no sms consent', 'message_id' => null, 'transient' => false];
|
||||
}
|
||||
$to = $lead['phone_e164'] ?? null;
|
||||
if (!$to || !Phone::isE164($to)) {
|
||||
return ['status' => 'failed', 'detail' => 'no valid phone', 'message_id' => null, 'transient' => false];
|
||||
}
|
||||
$content = self::buildConfirmation($lead, $campus);
|
||||
$from = self::sourceNumberFor($campus);
|
||||
$account = self::accountFor($campus);
|
||||
|
||||
if (Config::isMock()) {
|
||||
Log::info('[mock] Sinch send', ['account' => $account, 'source' => $from, 'chars' => mb_strlen($content)]);
|
||||
return ['status' => 'mock', 'detail' => 'mock mode; would send from ' . ($from ?? 'default') . ($account ? " as $account" : ''), 'message_id' => 'mock-' . substr($lead['submission_id'], 0, 8), 'transient' => false];
|
||||
}
|
||||
|
||||
$message = [
|
||||
'content' => $content,
|
||||
'destination_number' => $to,
|
||||
'format' => 'SMS',
|
||||
'delivery_report' => true,
|
||||
'metadata' => ['submission_id' => $lead['submission_id'], 'campus' => $campus['id'], 'source' => 'visit.waterschurch.org'],
|
||||
'message_expiry_timestamp' => gmdate('Y-m-d\TH:i:s\Z', time() + 6 * 3600),
|
||||
];
|
||||
if ($from) {
|
||||
$message['source_number'] = $from;
|
||||
}
|
||||
$r = self::post('/v1/messages', ['messages' => [$message]], $account);
|
||||
if ($r['status'] === 202 || $r['status'] === 200) {
|
||||
$msg = $r['json']['messages'][0] ?? [];
|
||||
$id = is_array($msg) ? ($msg['message_id'] ?? null) : null;
|
||||
$status = is_array($msg) ? ($msg['status'] ?? 'queued') : 'queued';
|
||||
// Only claim success when the provider actually accepted the message.
|
||||
if (!$id) {
|
||||
return ['status' => 'failed', 'detail' => 'provider response missing message_id', 'message_id' => null, 'transient' => true];
|
||||
}
|
||||
return ['status' => 'sent', 'detail' => "provider status: $status", 'message_id' => $id, 'transient' => false];
|
||||
}
|
||||
$detail = is_array($r['json']) ? json_encode($r['json']) : ($r['error'] ?? "HTTP {$r['status']}");
|
||||
Log::warn('Sinch send failed', ['status' => $r['status'], 'detail' => substr((string)$detail, 0, 300)]);
|
||||
return ['status' => 'failed', 'detail' => substr((string)$detail, 0, 500), 'message_id' => null, 'transient' => Http::isTransient($r['status'], $r['error'])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional internal heads-up to campus staff. Enabled only when
|
||||
* SINCH_INTERNAL_NOTIFY_NUMBER is set. Contains no visitor PII beyond first name.
|
||||
*
|
||||
* @param array<string,mixed> $lead
|
||||
* @param array<string,mixed> $campus
|
||||
*/
|
||||
public static function sendInternalLeadNotification(array $lead, array $campus): ?array
|
||||
{
|
||||
$to = Config::get('SINCH_INTERNAL_NOTIFY_NUMBER');
|
||||
if (!$to) {
|
||||
return null;
|
||||
}
|
||||
$content = sprintf('New first-time visitor plan: %s → %s%s. View in the leads export.', $lead['first_name'], $campus['name'], $lead['visit_date'] ? ' on ' . $lead['visit_date'] : '');
|
||||
if (Config::isMock()) {
|
||||
Log::info('[mock] Sinch internal notify', ['to' => $to]);
|
||||
return ['status' => 'mock'];
|
||||
}
|
||||
$r = self::post('/v1/messages', ['messages' => [['content' => $content, 'destination_number' => $to, 'format' => 'SMS', 'source_number' => Config::get('SINCH_DEFAULT_SOURCE_NUMBER')]]], null);
|
||||
return ['status' => ($r['status'] === 202 || $r['status'] === 200) ? 'sent' : 'failed'];
|
||||
}
|
||||
|
||||
public static function normalizePhoneNumber(string $raw): ?string
|
||||
{
|
||||
return Phone::normalizeNanp($raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $lead
|
||||
* @param array<string,mixed> $campus
|
||||
*/
|
||||
public static function buildConfirmation(array $lead, array $campus): string
|
||||
{
|
||||
$template = Config::get('SINCH_CONFIRMATION_TEMPLATE') ?: self::TEMPLATE_DEFAULT;
|
||||
$when = self::serviceOrDate($lead, $campus);
|
||||
$vars = [
|
||||
'{{first_name}}' => $lead['first_name'],
|
||||
'{{campus_name}}' => $campus['name'],
|
||||
'{{service_or_date}}' => $when,
|
||||
'{{address}}' => $campus['address']['line1'] . ', ' . $campus['address']['city'],
|
||||
];
|
||||
$text = strtr($template, $vars);
|
||||
$text = preg_replace('/\s+/', ' ', trim($text)) ?? $text;
|
||||
// Keep to a sane length; carriers concatenate but cost rises. 320 chars = 2 segments (GSM) or ~4 (UCS-2).
|
||||
return mb_substr($text, 0, 320);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $lead
|
||||
* @param array<string,mixed> $campus
|
||||
*/
|
||||
public static function serviceOrDate(array $lead, array $campus): string
|
||||
{
|
||||
$parts = [];
|
||||
if (!empty($lead['visit_date'])) {
|
||||
$ts = strtotime($lead['visit_date']);
|
||||
$parts[] = $ts ? date('D, M j', $ts) : $lead['visit_date'];
|
||||
}
|
||||
if (!empty($lead['service_time'])) {
|
||||
$parts[] = $lead['service_time'];
|
||||
}
|
||||
if (!$parts) {
|
||||
$parts[] = Campuses::timesSummary($campus);
|
||||
}
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $campus */
|
||||
public static function sourceNumberFor(array $campus): ?string
|
||||
{
|
||||
if (Config::bool('SINCH_USE_CAMPUS_NUMBERS', true) && !empty($campus['smsNumber'])) {
|
||||
return $campus['smsNumber'];
|
||||
}
|
||||
return Config::get('SINCH_DEFAULT_SOURCE_NUMBER');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $campus */
|
||||
public static function accountFor(array $campus): ?string
|
||||
{
|
||||
if (Config::bool('SINCH_USE_CAMPUS_SUBACCOUNTS', true) && !empty($campus['sinchAccountId'])) {
|
||||
return $campus['sinchAccountId'];
|
||||
}
|
||||
return Config::get('SINCH_ACCOUNT_ID');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array{status:int, body:string, json:mixed, error:?string}
|
||||
*/
|
||||
private static function post(string $path, array $payload, ?string $account): array
|
||||
{
|
||||
$base = rtrim(Config::get('SINCH_API_BASE', 'https://us.app.api.sinch.com') ?? '', '/');
|
||||
$key = Config::require('SINCH_API_KEY');
|
||||
$secret = Config::require('SINCH_API_SECRET');
|
||||
$headers = ['Authorization' => 'Basic ' . base64_encode("$key:$secret")];
|
||||
if ($account) {
|
||||
$headers['Account'] = $account;
|
||||
}
|
||||
return Http::json('POST', $base . $path, $payload, $headers, 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Optional Cloudflare Turnstile verification. Only enforced when TURNSTILE_SECRET_KEY is set. */
|
||||
final class Turnstile
|
||||
{
|
||||
public static function enabled(): bool
|
||||
{
|
||||
return Config::get('TURNSTILE_SECRET_KEY') !== null;
|
||||
}
|
||||
|
||||
public static function verify(?string $token, string $ip): bool
|
||||
{
|
||||
if (!self::enabled()) {
|
||||
return true;
|
||||
}
|
||||
if (!$token) {
|
||||
return false;
|
||||
}
|
||||
$res = Http::request('POST', 'https://challenges.cloudflare.com/turnstile/v0/siteverify', http_build_query([
|
||||
'secret' => Config::require('TURNSTILE_SECRET_KEY'),
|
||||
'response' => $token,
|
||||
'remoteip' => $ip,
|
||||
]), ['Content-Type' => 'application/x-www-form-urlencoded'], 6);
|
||||
if ($res['error'] !== null || $res['status'] !== 200) {
|
||||
// Fail open on provider outage so real visitors are never blocked; honeypot + rate limit still apply.
|
||||
Log::warn('Turnstile verification unavailable', ['status' => $res['status'], 'error' => $res['error']]);
|
||||
return true;
|
||||
}
|
||||
return (bool)($res['json']['success'] ?? false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Server-side validation and sanitization of the Plan Your Visit payload.
|
||||
* Mirrors src/lib/schema.ts. The server is authoritative.
|
||||
*/
|
||||
final class Validator
|
||||
{
|
||||
public const CHILD_AGE_RANGES = ['0-2', '3-5', '6-10', '11-13', '14-18'];
|
||||
private const ATTR_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'gclid', 'gbraid', 'wbraid', 'fbclid', 'ttclid', 'twclid', 'variant', 'landing_url', 'referrer', 'first_seen_at'];
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $in
|
||||
* @return array{data:array<string,mixed>, errors:array<string,string>}
|
||||
*/
|
||||
public static function planVisit(array $in): array
|
||||
{
|
||||
$errors = [];
|
||||
$d = [];
|
||||
|
||||
$d['submission_id'] = self::str($in, 'submissionId', 36);
|
||||
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $d['submission_id'])) {
|
||||
$errors['submissionId'] = 'Invalid submission id.';
|
||||
}
|
||||
|
||||
$d['first_name'] = self::str($in, 'firstName', 60);
|
||||
if ($d['first_name'] === '') {
|
||||
$errors['firstName'] = 'Please tell us your first name.';
|
||||
}
|
||||
$d['last_name'] = self::str($in, 'lastName', 60) ?: null;
|
||||
|
||||
$email = strtolower(self::str($in, 'email', 254));
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !str_contains($email, '.')) {
|
||||
$errors['email'] = "That email doesn't look right.";
|
||||
}
|
||||
$d['email'] = $email;
|
||||
|
||||
$d['email_consent'] = (bool)($in['emailConsent'] ?? false);
|
||||
$d['sms_consent'] = (bool)($in['smsConsent'] ?? false);
|
||||
|
||||
$phoneRaw = self::str($in, 'phone', 30);
|
||||
$d['phone_e164'] = null;
|
||||
if ($phoneRaw !== '') {
|
||||
$e164 = Phone::normalizeNanp($phoneRaw);
|
||||
if ($e164 === null) {
|
||||
$errors['phone'] = "That phone number doesn't look right.";
|
||||
} else {
|
||||
$d['phone_e164'] = $e164;
|
||||
}
|
||||
}
|
||||
if ($d['sms_consent'] && $d['phone_e164'] === null && !isset($errors['phone'])) {
|
||||
$errors['phone'] = 'Add a mobile number so we can text you, or uncheck texting.';
|
||||
}
|
||||
|
||||
$d['campus_id'] = self::str($in, 'campusId', 40);
|
||||
if (!in_array($d['campus_id'], Campuses::openIds(), true)) {
|
||||
$errors['campusId'] = 'Please choose a campus.';
|
||||
}
|
||||
|
||||
$visitDate = self::str($in, 'visitDate', 10);
|
||||
$d['visit_date'] = null;
|
||||
if ($visitDate !== '') {
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $visitDate) || !strtotime($visitDate)) {
|
||||
$errors['visitDate'] = 'Use the date picker.';
|
||||
} elseif ($visitDate < gmdate('Y-m-d', time() - 86400)) { // allow one day of timezone slack
|
||||
$errors['visitDate'] = 'Pick today or a future date.';
|
||||
} else {
|
||||
$d['visit_date'] = $visitDate;
|
||||
}
|
||||
}
|
||||
|
||||
$d['service_time'] = self::str($in, 'serviceTime', 80) ?: null;
|
||||
|
||||
$d['adults'] = self::intOrNull($in, 'adults', 1, 20);
|
||||
$d['children'] = self::intOrNull($in, 'children', 0, 10);
|
||||
|
||||
$ranges = $in['childAgeRanges'] ?? [];
|
||||
$clean = [];
|
||||
if (is_array($ranges)) {
|
||||
foreach ($ranges as $r) {
|
||||
if (is_string($r) && in_array($r, self::CHILD_AGE_RANGES, true) && !in_array($r, $clean, true)) {
|
||||
$clean[] = $r;
|
||||
}
|
||||
}
|
||||
}
|
||||
$d['child_age_ranges'] = $clean ? implode(',', $clean) : null;
|
||||
|
||||
$d['questions'] = self::str($in, 'questions', 1000) ?: null;
|
||||
|
||||
$d['consent_version'] = self::str($in, 'consentVersion', 40);
|
||||
if ($d['consent_version'] === '') {
|
||||
$errors['consentVersion'] = 'Missing consent version.';
|
||||
}
|
||||
|
||||
// Anti-spam: honeypot + minimum time on form
|
||||
$d['honeypot'] = self::str($in, 'website', 200);
|
||||
$startedAt = (int)($in['startedAt'] ?? 0);
|
||||
$d['elapsed_ms'] = $startedAt > 0 ? max(0, (int)(microtime(true) * 1000) - $startedAt) : 0;
|
||||
|
||||
// Attribution: whitelist keys, cap lengths, strip control chars
|
||||
$attr = [];
|
||||
if (isset($in['attribution']) && is_array($in['attribution'])) {
|
||||
foreach ($in['attribution'] as $k => $v) {
|
||||
if (is_string($k) && is_string($v) && in_array($k, self::ATTR_KEYS, true)) {
|
||||
$max = in_array($k, ['landing_url', 'referrer'], true) ? 2000 : 200;
|
||||
$attr[$k] = self::clean($v, $max);
|
||||
}
|
||||
}
|
||||
}
|
||||
$d['attribution'] = $attr;
|
||||
$d['turnstile_token'] = is_string($in['turnstileToken'] ?? null) ? substr($in['turnstileToken'], 0, 4000) : null;
|
||||
|
||||
return ['data' => $d, 'errors' => $errors];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $in */
|
||||
private static function str(array $in, string $key, int $max): string
|
||||
{
|
||||
$v = $in[$key] ?? '';
|
||||
if (!is_string($v)) {
|
||||
return '';
|
||||
}
|
||||
return self::clean($v, $max);
|
||||
}
|
||||
|
||||
public static function clean(string $v, int $max): string
|
||||
{
|
||||
$v = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $v) ?? '';
|
||||
$v = trim($v);
|
||||
if (!mb_check_encoding($v, 'UTF-8')) {
|
||||
$v = mb_convert_encoding($v, 'UTF-8', 'UTF-8');
|
||||
}
|
||||
return mb_substr($v, 0, $max);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $in */
|
||||
private static function intOrNull(array $in, string $key, int $min, int $max): ?int
|
||||
{
|
||||
if (!isset($in[$key]) || $in[$key] === '' || $in[$key] === null) {
|
||||
return null;
|
||||
}
|
||||
if (!is_numeric($in[$key])) {
|
||||
return null;
|
||||
}
|
||||
$n = (int)$in[$key];
|
||||
return ($n < $min || $n > $max) ? null : $n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
/**
|
||||
* Shared bootstrap for every API endpoint.
|
||||
*
|
||||
* - Loads configuration from a private .env OUTSIDE the web root (see Config.php)
|
||||
* - Hardens error output (never leak stack traces or secrets to the browser)
|
||||
* - Provides JSON response helpers and same-origin protection
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
if (PHP_VERSION_ID < 80100) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['ok' => false, 'error' => 'Server misconfiguration (PHP 8.1+ required).']);
|
||||
exit;
|
||||
}
|
||||
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
date_default_timezone_set('UTC');
|
||||
|
||||
require_once __DIR__ . '/Config.php';
|
||||
require_once __DIR__ . '/Log.php';
|
||||
require_once __DIR__ . '/Db.php';
|
||||
require_once __DIR__ . '/Http.php';
|
||||
require_once __DIR__ . '/Campuses.php';
|
||||
require_once __DIR__ . '/Phone.php';
|
||||
|
||||
Config::load();
|
||||
Log::init(Config::privateDir() . '/logs/app.log', Config::get('LOG_LEVEL', 'info'));
|
||||
|
||||
set_exception_handler(static function (Throwable $e): void {
|
||||
Log::error('Unhandled exception', ['type' => get_class($e), 'message' => $e->getMessage(), 'file' => basename($e->getFile()), 'line' => $e->getLine()]);
|
||||
Api::json(['ok' => false, 'error' => 'Something went wrong on our end. Please try again in a moment.'], 500);
|
||||
});
|
||||
|
||||
set_error_handler(static function (int $no, string $str, string $file, int $line): bool {
|
||||
Log::warn('PHP notice', ['no' => $no, 'message' => $str, 'file' => basename($file), 'line' => $line]);
|
||||
return true;
|
||||
});
|
||||
|
||||
final class Api
|
||||
{
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function json(array $payload, int $status = 200, array $headers = []): never
|
||||
{
|
||||
if (!headers_sent()) {
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Referrer-Policy: same-origin');
|
||||
foreach ($headers as $k => $v) {
|
||||
header("$k: $v");
|
||||
}
|
||||
}
|
||||
echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function requireMethod(string ...$methods): void
|
||||
{
|
||||
$m = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
if ($m === 'OPTIONS') {
|
||||
self::corsHeaders();
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
if (!in_array($m, $methods, true)) {
|
||||
self::json(['ok' => false, 'error' => 'Method not allowed'], 405, ['Allow' => implode(', ', $methods)]);
|
||||
}
|
||||
self::corsHeaders();
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-origin protection for a static site: the browser always sends Origin
|
||||
* (or at least Referer) on cross-site fetches. Allowed origins are the
|
||||
* production URL plus anything in ALLOWED_ORIGINS (comma separated, for
|
||||
* local development).
|
||||
*/
|
||||
public static function requireSameOrigin(): void
|
||||
{
|
||||
$allowed = self::allowedOrigins();
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? null;
|
||||
$referer = $_SERVER['HTTP_REFERER'] ?? null;
|
||||
$candidate = $origin ?: ($referer ? self::originOf($referer) : null);
|
||||
$custom = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
|
||||
|
||||
if ($candidate === null) {
|
||||
// No Origin/Referer at all: only accept if our custom header is present (fetch sets it; simple forms cannot).
|
||||
if ($custom !== 'visit-page') {
|
||||
self::json(['ok' => false, 'error' => 'Request blocked.'], 403);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!in_array(rtrim($candidate, '/'), $allowed, true)) {
|
||||
Log::warn('Cross-origin request rejected', ['origin' => $candidate]);
|
||||
self::json(['ok' => false, 'error' => 'Request blocked.'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public static function allowedOrigins(): array
|
||||
{
|
||||
$list = [rtrim(Config::get('PUBLIC_SITE_URL', 'https://visit.waterschurch.org'), '/')];
|
||||
foreach (explode(',', Config::get('ALLOWED_ORIGINS', '')) as $o) {
|
||||
$o = rtrim(trim($o), '/');
|
||||
if ($o !== '') {
|
||||
$list[] = $o;
|
||||
}
|
||||
}
|
||||
// Same host as the request (covers preview subdomains on the same server).
|
||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
if ($host !== '') {
|
||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$list[] = "$scheme://$host";
|
||||
}
|
||||
return array_values(array_unique($list));
|
||||
}
|
||||
|
||||
private static function corsHeaders(): void
|
||||
{
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if ($origin !== '' && in_array(rtrim($origin, '/'), self::allowedOrigins(), true)) {
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
header('Vary: Origin');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, X-Requested-With');
|
||||
header('Access-Control-Max-Age: 600');
|
||||
}
|
||||
}
|
||||
|
||||
private static function originOf(string $url): ?string
|
||||
{
|
||||
$p = parse_url($url);
|
||||
if (!$p || empty($p['host']) || empty($p['scheme'])) {
|
||||
return null;
|
||||
}
|
||||
$port = isset($p['port']) ? ':' . $p['port'] : '';
|
||||
return strtolower($p['scheme'] . '://' . $p['host'] . $port);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function readJsonBody(int $maxBytes = 32768): array
|
||||
{
|
||||
$len = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
|
||||
if ($len > $maxBytes) {
|
||||
self::json(['ok' => false, 'error' => 'Request too large.'], 413);
|
||||
}
|
||||
$raw = file_get_contents('php://input', false, null, 0, $maxBytes + 1) ?: '';
|
||||
if (strlen($raw) > $maxBytes) {
|
||||
self::json(['ok' => false, 'error' => 'Request too large.'], 413);
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
self::json(['ok' => false, 'error' => 'Invalid request body.'], 400);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** Best-effort client IP (respects cPanel/Cloudflare proxies only when configured). */
|
||||
public static function clientIp(): string
|
||||
{
|
||||
$trust = Config::bool('TRUST_PROXY_HEADERS', false);
|
||||
if ($trust) {
|
||||
foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR'] as $h) {
|
||||
if (!empty($_SERVER[$h])) {
|
||||
$ip = trim(explode(',', (string)$_SERVER[$h])[0]);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /api/locate.php — approximate, non-invasive location for the campus recommendation.
|
||||
* Returns city-level coordinates only. Nothing is stored beyond a short hashed cache.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/lib/bootstrap.php';
|
||||
require_once __DIR__ . '/lib/Validator.php';
|
||||
require_once __DIR__ . '/lib/RateLimit.php';
|
||||
require_once __DIR__ . '/lib/GeoIp.php';
|
||||
|
||||
Api::requireMethod('GET');
|
||||
|
||||
$ip = Api::clientIp();
|
||||
$rl = RateLimit::hit(RateLimit::ipBucket('locate', $ip), 30, 600);
|
||||
if (!$rl['allowed']) {
|
||||
Api::json(['ok' => false, 'reason' => 'rate_limited'], 429, ['Retry-After' => (string)$rl['retryAfter']]);
|
||||
}
|
||||
|
||||
// Local development helper: ?debug_ip=8.8.8.8 (only when DEBUG_GEO=true)
|
||||
if (Config::bool('DEBUG_GEO', false) && !empty($_GET['debug_ip']) && filter_var($_GET['debug_ip'], FILTER_VALIDATE_IP)) {
|
||||
$ip = (string)$_GET['debug_ip'];
|
||||
}
|
||||
if (Config::bool('DEBUG_GEO', false) && isset($_GET['debug_lat'], $_GET['debug_lng'])) {
|
||||
Api::json(['ok' => true, 'approximate' => ['lat' => (float)$_GET['debug_lat'], 'lng' => (float)$_GET['debug_lng'], 'city' => 'Debug', 'confidence' => 'low']]);
|
||||
}
|
||||
|
||||
$loc = GeoIp::locate($ip);
|
||||
if (!$loc) {
|
||||
Api::json(['ok' => false, 'reason' => 'unavailable'], 200, ['Cache-Control' => 'private, max-age=300']);
|
||||
}
|
||||
Api::json([
|
||||
'ok' => true,
|
||||
'approximate' => ['lat' => $loc['lat'], 'lng' => $loc['lng'], 'city' => $loc['city'], 'region' => $loc['region'], 'confidence' => 'low'],
|
||||
], 200, ['Cache-Control' => 'private, max-age=3600']);
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/plan-visit.php
|
||||
*
|
||||
* 1. Same-origin + method checks
|
||||
* 2. Rate limit (per IP) and bot checks (honeypot, minimum time, optional Turnstile)
|
||||
* 3. Validate + normalize
|
||||
* 4. Persist the lead (idempotent on submissionId)
|
||||
* 5. Run Mailchimp / Sinch, recording each outcome for retry
|
||||
* 6. Return a friendly summary — never provider internals
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/lib/bootstrap.php';
|
||||
require_once __DIR__ . '/lib/Validator.php';
|
||||
require_once __DIR__ . '/lib/RateLimit.php';
|
||||
require_once __DIR__ . '/lib/Turnstile.php';
|
||||
require_once __DIR__ . '/lib/LeadProcessor.php';
|
||||
|
||||
Api::requireMethod('POST');
|
||||
Api::requireSameOrigin();
|
||||
|
||||
$ip = Api::clientIp();
|
||||
$ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
|
||||
|
||||
// --- Rate limiting: 5 submissions / 10 minutes per IP, 60 / day per IP
|
||||
$rl = RateLimit::hit(RateLimit::ipBucket('submit:10m', $ip), Config::int('RATE_LIMIT_SUBMIT_PER_10MIN', 5), 600);
|
||||
if (!$rl['allowed']) {
|
||||
Api::json(['ok' => false, 'error' => 'Too many attempts. Please wait a moment and try again.', 'retryAfter' => $rl['retryAfter']], 429, ['Retry-After' => (string)$rl['retryAfter']]);
|
||||
}
|
||||
$rlDay = RateLimit::hit(RateLimit::ipBucket('submit:1d', $ip), Config::int('RATE_LIMIT_SUBMIT_PER_DAY', 60), 86400);
|
||||
if (!$rlDay['allowed']) {
|
||||
Api::json(['ok' => false, 'error' => 'Too many attempts today. Text us and we will help directly.', 'retryAfter' => $rlDay['retryAfter']], 429, ['Retry-After' => (string)$rlDay['retryAfter']]);
|
||||
}
|
||||
|
||||
$input = Api::readJsonBody();
|
||||
['data' => $d, 'errors' => $errors] = Validator::planVisit($input);
|
||||
|
||||
// --- Bot checks. Respond with a fake success so bots learn nothing.
|
||||
if ($d['honeypot'] !== '' || ($d['elapsed_ms'] > 0 && $d['elapsed_ms'] < Config::int('MIN_FORM_SECONDS', 3) * 1000)) {
|
||||
Log::warn('Bot signal on submit', ['honeypot' => $d['honeypot'] !== '', 'elapsed_ms' => $d['elapsed_ms']]);
|
||||
Api::json(['ok' => true, 'submissionId' => $d['submission_id'], 'email' => ['status' => 'skipped'], 'sms' => ['status' => 'skipped']]);
|
||||
}
|
||||
if (!Turnstile::verify($d['turnstile_token'], $ip)) {
|
||||
Api::json(['ok' => false, 'error' => 'Security check failed. Please try again.'], 400);
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
Api::json(['ok' => false, 'error' => 'Please check the highlighted fields.', 'fieldErrors' => $errors], 422);
|
||||
}
|
||||
|
||||
// --- Persist first
|
||||
['lead' => $lead, 'duplicate' => $duplicate] = LeadProcessor::persist($d, $ip, $ua);
|
||||
Log::info($duplicate ? 'Duplicate submission (idempotent replay)' : 'Lead stored', ['lead' => $lead['id'], 'campus' => $lead['campus_id'], 'email_consent' => (bool)$lead['email_consent'], 'sms_consent' => (bool)$lead['sms_consent']]);
|
||||
|
||||
// --- Integrations (each guarded; failures are recorded for cron retry)
|
||||
$statuses = LeadProcessor::runIntegrations($lead);
|
||||
|
||||
$public = static function (string $s): string {
|
||||
return match ($s) {
|
||||
'sent' => 'sent',
|
||||
'mock' => 'mock',
|
||||
'skipped' => 'skipped',
|
||||
'failed' => 'failed',
|
||||
default => 'queued', // pending / retry / sending
|
||||
};
|
||||
};
|
||||
|
||||
Api::json([
|
||||
'ok' => true,
|
||||
'submissionId' => $lead['submission_id'],
|
||||
'duplicate' => $duplicate,
|
||||
'email' => ['status' => $public($statuses['email'])],
|
||||
'sms' => ['status' => $public($statuses['sms'])],
|
||||
]);
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Generates optimized, responsive images from assets/source into public/images.
|
||||
* Runs automatically before `next build`. Safe to re-run; skips up-to-date files.
|
||||
*
|
||||
* npm run images
|
||||
*/
|
||||
import sharp from "sharp";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const SRC = path.join(ROOT, "assets/source");
|
||||
const OUT = path.join(ROOT, "public/images");
|
||||
|
||||
/** name → { file, widths, quality } */
|
||||
const PHOTOS = {
|
||||
"hero-worship": { file: "photos/hero-north-attleboro-worship.jpg", widths: [640, 1024, 1600, 2000], quality: 72 },
|
||||
"campus-north-attleboro": { file: "photos/campus-north-attleboro.jpg", widths: [480, 960], quality: 74 },
|
||||
"campus-norwood": { file: "photos/campus-norwood.jpg", widths: [480, 960], quality: 74 },
|
||||
"campus-woonsocket": { file: "photos/campus-woonsocket.jpg", widths: [480, 960], quality: 74 },
|
||||
"campus-southcoast": { file: "photos/campus-southcoast.jpg", widths: [480, 960], quality: 74 },
|
||||
"campus-apollo-beach": { file: "photos/campus-apollo-beach.jpg", widths: [480, 960], quality: 74 },
|
||||
"campus-orlando": { file: "photos/campus-orlando.jpg", widths: [480, 960], quality: 74 },
|
||||
"worship-southcoast": { file: "photos/worship-southcoast.jpg", widths: [640, 1200], quality: 72 },
|
||||
"community-1": { file: "photos/community-1.jpg", widths: [640, 1200], quality: 74 },
|
||||
"save-a-seat": { file: "photos/save-a-seat.jpg", widths: [640, 1200], quality: 74 },
|
||||
};
|
||||
|
||||
const LOGOS = {
|
||||
"waters-mark-teal": { file: "logos/waters-mark-teal.png", widths: [96, 192, 512] },
|
||||
};
|
||||
|
||||
const SVGS = ["logos/waters-mark-white.svg", "logos/waters-church-wordmark-white.svg"];
|
||||
|
||||
async function newer(src, out) {
|
||||
try {
|
||||
const [a, b] = await Promise.all([fs.stat(src), fs.stat(out)]);
|
||||
return a.mtimeMs > b.mtimeMs;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT, { recursive: true });
|
||||
const manifest = {};
|
||||
|
||||
for (const [name, spec] of Object.entries(PHOTOS)) {
|
||||
const src = path.join(SRC, spec.file);
|
||||
const meta = await sharp(src).metadata();
|
||||
manifest[name] = { width: meta.width, height: meta.height, widths: [], formats: ["avif", "webp", "jpg"] };
|
||||
for (const w of spec.widths) {
|
||||
if (w > meta.width) continue;
|
||||
manifest[name].widths.push(w);
|
||||
const base = path.join(OUT, `${name}-${w}`);
|
||||
const resized = () => sharp(src).resize({ width: w, withoutEnlargement: true });
|
||||
if (await newer(src, `${base}.webp`)) await resized().webp({ quality: spec.quality }).toFile(`${base}.webp`);
|
||||
if (await newer(src, `${base}.avif`)) await resized().avif({ quality: Math.max(40, spec.quality - 20) }).toFile(`${base}.avif`);
|
||||
if (await newer(src, `${base}.jpg`)) await resized().jpeg({ quality: spec.quality + 4, mozjpeg: true }).toFile(`${base}.jpg`);
|
||||
}
|
||||
// tiny blurred placeholder to avoid layout flash
|
||||
const { data } = await sharp(src).resize(24).webp({ quality: 30 }).toBuffer({ resolveWithObject: true });
|
||||
manifest[name].blur = `data:image/webp;base64,${data.toString("base64")}`;
|
||||
}
|
||||
|
||||
for (const [name, spec] of Object.entries(LOGOS)) {
|
||||
const src = path.join(SRC, spec.file);
|
||||
for (const w of spec.widths) {
|
||||
const out = path.join(OUT, `${name}-${w}.png`);
|
||||
if (await newer(src, out)) await sharp(src).resize({ width: w }).png({ compressionLevel: 9 }).toFile(out);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rel of SVGS) {
|
||||
const out = path.join(OUT, path.basename(rel));
|
||||
if (await newer(path.join(SRC, rel), out)) await fs.copyFile(path.join(SRC, rel), out);
|
||||
}
|
||||
|
||||
// Favicons / social image
|
||||
const mark = path.join(SRC, LOGOS["waters-mark-teal"].file);
|
||||
await sharp(mark).resize(32).png().toFile(path.join(OUT, "favicon-32.png"));
|
||||
await sharp(mark).resize(180).png().toFile(path.join(OUT, "apple-touch-icon.png"));
|
||||
await sharp(mark).resize(512).png().toFile(path.join(OUT, "icon-512.png"));
|
||||
const og = path.join(OUT, "og-image.jpg");
|
||||
if (await newer(path.join(SRC, PHOTOS["hero-worship"].file), og)) {
|
||||
await sharp(path.join(SRC, PHOTOS["hero-worship"].file))
|
||||
.resize(1200, 630, { fit: "cover", position: "centre" })
|
||||
.jpeg({ quality: 78, mozjpeg: true })
|
||||
.toFile(og);
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(ROOT, "src/config/images.manifest.json"), JSON.stringify(manifest, null, 2));
|
||||
const files = await fs.readdir(OUT);
|
||||
let total = 0;
|
||||
for (const f of files) total += (await fs.stat(path.join(OUT, f))).size;
|
||||
console.log(`images: ${files.length} files, ${(total / 1024).toFixed(0)} KB total in public/images`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Creates dist/visit-waterschurch-<version>-<date>.zip from ./out for upload
|
||||
* through cPanel File Manager (Upload → Extract). Also copies private/.env.example
|
||||
* next to it as a reminder of what must be configured server-side.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const OUT = path.join(ROOT, "out");
|
||||
const DIST = path.join(ROOT, "dist");
|
||||
|
||||
const pkg = JSON.parse(await fs.readFile(path.join(ROOT, "package.json"), "utf8"));
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
await fs.mkdir(DIST, { recursive: true });
|
||||
const zip = path.join(DIST, `visit-waterschurch-${pkg.version}-${stamp}.zip`);
|
||||
await fs.rm(zip, { force: true });
|
||||
// -X strips extended attributes; -r recursive; . includes dot-files (.htaccess)
|
||||
execFileSync("zip", ["-X", "-r", zip, "."], { cwd: OUT, stdio: "inherit" });
|
||||
await fs.copyFile(path.join(ROOT, ".env.example"), path.join(DIST, "env.example.txt"));
|
||||
const size = (await fs.stat(zip)).size;
|
||||
console.log(`package: ${path.relative(ROOT, zip)} (${(size / 1024 / 1024).toFixed(1)} MB)`);
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Post-build: make ./out a complete, uploadable cPanel document root.
|
||||
* - copy deploy/htaccess.root → out/.htaccess
|
||||
* - copy config/campuses.json → out/api/config/campuses.json (read by PHP)
|
||||
* - ensure the private lib/cron folders keep their .htaccess deny rules
|
||||
* - write out/BUILD.txt with the build id
|
||||
*/
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const OUT = path.join(ROOT, "out");
|
||||
|
||||
async function exists(p) {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!(await exists(OUT))) throw new Error("out/ not found — run `next build` first");
|
||||
|
||||
await fs.copyFile(path.join(ROOT, "deploy/htaccess.root"), path.join(OUT, ".htaccess"));
|
||||
|
||||
await fs.mkdir(path.join(OUT, "api/config"), { recursive: true });
|
||||
await fs.copyFile(path.join(ROOT, "config/campuses.json"), path.join(OUT, "api/config/campuses.json"));
|
||||
await fs.writeFile(path.join(OUT, "api/config/.htaccess"), "Require all denied\n");
|
||||
|
||||
for (const dir of ["api/lib", "api/cron"]) {
|
||||
const ht = path.join(OUT, dir, ".htaccess");
|
||||
if (!(await exists(ht))) await fs.writeFile(ht, "Require all denied\n");
|
||||
}
|
||||
|
||||
// Next writes 404.html for the not-found route; make sure it exists for ErrorDocument
|
||||
if (!(await exists(path.join(OUT, "404.html")))) {
|
||||
console.warn("warning: out/404.html missing — custom 404 will not work");
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(await fs.readFile(path.join(ROOT, "package.json"), "utf8"));
|
||||
await fs.writeFile(path.join(OUT, "BUILD.txt"), `${pkg.name} ${pkg.version}\nbuilt ${new Date().toISOString()}\n`);
|
||||
|
||||
// Report
|
||||
let files = 0;
|
||||
let bytes = 0;
|
||||
async function walk(d) {
|
||||
for (const e of await fs.readdir(d, { withFileTypes: true })) {
|
||||
const p = path.join(d, e.name);
|
||||
if (e.isDirectory()) await walk(p);
|
||||
else {
|
||||
files++;
|
||||
bytes += (await fs.stat(p)).size;
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(OUT);
|
||||
console.log(`postbuild: out/ ready — ${files} files, ${(bytes / 1024 / 1024).toFixed(1)} MB`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
@@ -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 }];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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's your first visit and they'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) }} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}` })),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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 = [];
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { _resetAnalyticsQueue, track } from "@/lib/analytics";
|
||||
|
||||
describe("analytics layer", () => {
|
||||
beforeEach(() => {
|
||||
window.dataLayer = [];
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState({}, "", "/?utm_source=google&utm_campaign=brand&gclid=G");
|
||||
_resetAnalyticsQueue();
|
||||
});
|
||||
|
||||
it("pushes named events with attribution and device props, without PII", () => {
|
||||
track("campus_selected", { campus: "norwood", selection_source: "manual" });
|
||||
const evt = (window.dataLayer as Record<string, unknown>[]).at(-1)!;
|
||||
expect(evt.event).toBe("campus_selected");
|
||||
expect(evt.campus).toBe("norwood");
|
||||
expect(evt.source).toBe("google");
|
||||
expect(evt.campaign).toBe("brand");
|
||||
expect(evt.platform).toBe("google");
|
||||
expect(evt.variant).toBe("a");
|
||||
expect(["mobile", "tablet", "desktop"]).toContain(evt.device_category);
|
||||
expect(JSON.stringify(evt)).not.toMatch(/@|phone/);
|
||||
});
|
||||
|
||||
it("dispatches a DOM event for custom listeners", () => {
|
||||
const seen: string[] = [];
|
||||
window.addEventListener("wc:analytics", (e) => seen.push((e as CustomEvent).detail.event));
|
||||
track("text_us_clicked", { location: "header" });
|
||||
expect(seen).toEqual(["text_us_clicked"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { attributionForApi, getAttribution, inferPlatform, parseAttribution } from "@/lib/attribution";
|
||||
|
||||
describe("parseAttribution", () => {
|
||||
const now = new Date("2026-09-22T12:00:00Z");
|
||||
it("captures utm, click ids, variant, referrer and landing url", () => {
|
||||
const a = parseAttribution(
|
||||
"https://visit.waterschurch.org/?utm_source=facebook&utm_medium=paid_social&utm_campaign=fall-visit&utm_content=video1&utm_term=church+near+me&fbclid=ABC123&v=b",
|
||||
"https://l.facebook.com/",
|
||||
now,
|
||||
"a",
|
||||
);
|
||||
expect(a.utm_source).toBe("facebook");
|
||||
expect(a.utm_medium).toBe("paid_social");
|
||||
expect(a.utm_campaign).toBe("fall-visit");
|
||||
expect(a.utm_content).toBe("video1");
|
||||
expect(a.utm_term).toBe("church near me");
|
||||
expect(a.fbclid).toBe("ABC123");
|
||||
expect(a.variant).toBe("b");
|
||||
expect(a.referrer).toBe("https://l.facebook.com/");
|
||||
expect(a.landing_url).toContain("utm_campaign=fall-visit");
|
||||
expect(a.first_seen_at).toBe(now.toISOString());
|
||||
});
|
||||
it("supports Google, TikTok and X identifiers", () => {
|
||||
const a = parseAttribution("https://x.test/?gclid=G1&gbraid=GB&wbraid=WB&ttclid=T1&twclid=X1", "", now, "a");
|
||||
expect(a).toMatchObject({ gclid: "G1", gbraid: "GB", wbraid: "WB", ttclid: "T1", twclid: "X1" });
|
||||
});
|
||||
it("accepts tt_click_id as a TikTok alias", () => {
|
||||
expect(parseAttribution("https://x.test/?tt_click_id=T9", "", now, "a").ttclid).toBe("T9");
|
||||
});
|
||||
it("tolerates missing or malformed parameters", () => {
|
||||
const a = parseAttribution("not a url", "", now, "a");
|
||||
expect(a.variant).toBe("a");
|
||||
expect(a.utm_source).toBeUndefined();
|
||||
const b = parseAttribution("https://x.test/?utm_source=&gclid=%3Cscript%3Ealert(1)%3C/script%3E", "", now, "a");
|
||||
expect(b.utm_source).toBeUndefined();
|
||||
expect(b.gclid).toBe("scriptalert(1)/script");
|
||||
});
|
||||
it("caps overly long values", () => {
|
||||
const long = "x".repeat(500);
|
||||
expect(parseAttribution(`https://x.test/?utm_campaign=${long}`, "", now, "a").utm_campaign).toHaveLength(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferPlatform", () => {
|
||||
it("derives the ad platform", () => {
|
||||
expect(inferPlatform({ gclid: "1" })).toBe("google");
|
||||
expect(inferPlatform({ fbclid: "1" })).toBe("facebook");
|
||||
expect(inferPlatform({ utm_source: "instagram" })).toBe("instagram");
|
||||
expect(inferPlatform({ ttclid: "1" })).toBe("tiktok");
|
||||
expect(inferPlatform({ utm_source: "x" })).toBe("x");
|
||||
expect(inferPlatform({})).toBe("direct");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAttribution (browser)", () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
window.history.replaceState({}, "", "/?utm_source=tiktok&utm_campaign=c1&ttclid=T1");
|
||||
});
|
||||
it("captures once and persists across calls", () => {
|
||||
const first = getAttribution("a");
|
||||
expect(first.utm_source).toBe("tiktok");
|
||||
window.history.replaceState({}, "", "/");
|
||||
const second = getAttribution("a");
|
||||
expect(second.utm_campaign).toBe("c1");
|
||||
expect(second.first_seen_at).toBe(first.first_seen_at);
|
||||
});
|
||||
it("merges a new click id from a second ad click without losing first-touch data", () => {
|
||||
const first = getAttribution("a");
|
||||
window.history.replaceState({}, "", "/?utm_source=facebook&fbclid=F1");
|
||||
const merged = getAttribution("a");
|
||||
expect(merged.fbclid).toBe("F1");
|
||||
expect(merged.ttclid).toBe("T1");
|
||||
expect(merged.first_seen_at).toBe(first.first_seen_at);
|
||||
});
|
||||
it("lets an explicit ?v= override the session variant", () => {
|
||||
expect(getAttribution("a").variant).toBe("a");
|
||||
window.history.replaceState({}, "", "/?v=b");
|
||||
expect(getAttribution("a").variant).toBe("b");
|
||||
});
|
||||
it("flattens to strings for the API", () => {
|
||||
const flat = attributionForApi(getAttribution("a"));
|
||||
for (const v of Object.values(flat)) expect(typeof v).toBe("string");
|
||||
expect(flat.utm_source).toBe("tiktok");
|
||||
});
|
||||
});
|
||||