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>
173 lines
8.0 KiB
PHP
173 lines
8.0 KiB
PHP
<?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';
|
|
}
|
|
}
|