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>
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(fornpm 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 testruns the TypeScript suite (jsdom).npm run test:phpspins up fake Mailchimp/Sinch/geocode servers on127.0.0.1:8099and the real API on127.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 withVISIT_PRIVATE_DIR=privatelocally).
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
- In Mailchimp: Profile → Extras → API keys → Create a key. The key ends in
-usXX; that is yourMAILCHIMP_SERVER_PREFIX. - Audience → Settings → Audience name and defaults → copy the Audience ID.
- 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. - Tags applied automatically (prefix configurable via
MAILCHIMP_TAG_PREFIX, defaultVisit:), matching the church's existingVisit:<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>orVisit:Service <time>,Visit:Has Kids,Visit:SMS Opt-In. - Welcome journey — two options:
- Tag-triggered (simplest): build a Customer Journey whose starting point is
"Tag added →
Visit:First-Time Visitor" (all campuses) orVisit:<Campus>(one per campus). The "New Visit" journey created 2026-09-22 triggers onVisit:Apollo Beachonly. - 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.
- Tag-triggered (simplest): build a Customer Journey whose starting point is
"Tag added →
- Consent: contacts are only created when the visitor ticks the email box.
status_if_newissubscribed(single opt-in). SetMAILCHIMP_DOUBLE_OPTIN=trueto 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) |
-
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
Accountheader, which the adapter sets automatically (SINCH_USE_CAMPUS_SUBACCOUNTS=true). -
Set
SINCH_API_BASEto the regional host for the account (US:https://us.app.api.sinch.com). -
Endpoint used:
POST /v1/messageswithcontent,destination_number,source_number,format: "SMS",delivery_report: true,metadata,message_expiry_timestamp. Success = HTTP 202 and amessage_idin the response; anything else is recorded as failed and retried by cron if transient. -
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.
-
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.
-
Optional:
SINCH_INTERNAL_NOTIFY_NUMBERsends 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), ornone. 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, orgoogle(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.