Files

189 lines
9.5 KiB
Markdown
Raw Permalink Normal View History

# 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).