Files
visit.waterschurch.org/docs/SETUP.md
T
Waters ChurchandClaude Fable 5.1 78cd0cc550 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>
2026-09-22 13:22:26 -04:00

9.5 KiB

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

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:

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

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.