Plan Your Visit landing page for visit.waterschurch.org

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-22 13:22:26 -04:00
co-authored by Claude Fable 5.1
commit 78cd0cc550
109 changed files with 16861 additions and 0 deletions
+194
View File
@@ -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.
+84
View File
@@ -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).
+188
View File
@@ -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).