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>
76 lines
3.2 KiB
PHP
76 lines
3.2 KiB
PHP
<?php
|
|
/**
|
|
* POST /api/plan-visit.php
|
|
*
|
|
* 1. Same-origin + method checks
|
|
* 2. Rate limit (per IP) and bot checks (honeypot, minimum time, optional Turnstile)
|
|
* 3. Validate + normalize
|
|
* 4. Persist the lead (idempotent on submissionId)
|
|
* 5. Run Mailchimp / Sinch, recording each outcome for retry
|
|
* 6. Return a friendly summary — never provider internals
|
|
*/
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/lib/bootstrap.php';
|
|
require_once __DIR__ . '/lib/Validator.php';
|
|
require_once __DIR__ . '/lib/RateLimit.php';
|
|
require_once __DIR__ . '/lib/Turnstile.php';
|
|
require_once __DIR__ . '/lib/LeadProcessor.php';
|
|
|
|
Api::requireMethod('POST');
|
|
Api::requireSameOrigin();
|
|
|
|
$ip = Api::clientIp();
|
|
$ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
|
|
|
|
// --- Rate limiting: 5 submissions / 10 minutes per IP, 60 / day per IP
|
|
$rl = RateLimit::hit(RateLimit::ipBucket('submit:10m', $ip), Config::int('RATE_LIMIT_SUBMIT_PER_10MIN', 5), 600);
|
|
if (!$rl['allowed']) {
|
|
Api::json(['ok' => false, 'error' => 'Too many attempts. Please wait a moment and try again.', 'retryAfter' => $rl['retryAfter']], 429, ['Retry-After' => (string)$rl['retryAfter']]);
|
|
}
|
|
$rlDay = RateLimit::hit(RateLimit::ipBucket('submit:1d', $ip), Config::int('RATE_LIMIT_SUBMIT_PER_DAY', 60), 86400);
|
|
if (!$rlDay['allowed']) {
|
|
Api::json(['ok' => false, 'error' => 'Too many attempts today. Text us and we will help directly.', 'retryAfter' => $rlDay['retryAfter']], 429, ['Retry-After' => (string)$rlDay['retryAfter']]);
|
|
}
|
|
|
|
$input = Api::readJsonBody();
|
|
['data' => $d, 'errors' => $errors] = Validator::planVisit($input);
|
|
|
|
// --- Bot checks. Respond with a fake success so bots learn nothing.
|
|
if ($d['honeypot'] !== '' || ($d['elapsed_ms'] > 0 && $d['elapsed_ms'] < Config::int('MIN_FORM_SECONDS', 3) * 1000)) {
|
|
Log::warn('Bot signal on submit', ['honeypot' => $d['honeypot'] !== '', 'elapsed_ms' => $d['elapsed_ms']]);
|
|
Api::json(['ok' => true, 'submissionId' => $d['submission_id'], 'email' => ['status' => 'skipped'], 'sms' => ['status' => 'skipped']]);
|
|
}
|
|
if (!Turnstile::verify($d['turnstile_token'], $ip)) {
|
|
Api::json(['ok' => false, 'error' => 'Security check failed. Please try again.'], 400);
|
|
}
|
|
|
|
if ($errors) {
|
|
Api::json(['ok' => false, 'error' => 'Please check the highlighted fields.', 'fieldErrors' => $errors], 422);
|
|
}
|
|
|
|
// --- Persist first
|
|
['lead' => $lead, 'duplicate' => $duplicate] = LeadProcessor::persist($d, $ip, $ua);
|
|
Log::info($duplicate ? 'Duplicate submission (idempotent replay)' : 'Lead stored', ['lead' => $lead['id'], 'campus' => $lead['campus_id'], 'email_consent' => (bool)$lead['email_consent'], 'sms_consent' => (bool)$lead['sms_consent']]);
|
|
|
|
// --- Integrations (each guarded; failures are recorded for cron retry)
|
|
$statuses = LeadProcessor::runIntegrations($lead);
|
|
|
|
$public = static function (string $s): string {
|
|
return match ($s) {
|
|
'sent' => 'sent',
|
|
'mock' => 'mock',
|
|
'skipped' => 'skipped',
|
|
'failed' => 'failed',
|
|
default => 'queued', // pending / retry / sending
|
|
};
|
|
};
|
|
|
|
Api::json([
|
|
'ok' => true,
|
|
'submissionId' => $lead['submission_id'],
|
|
'duplicate' => $duplicate,
|
|
'email' => ['status' => $public($statuses['email'])],
|
|
'sms' => ['status' => $public($statuses['sms'])],
|
|
]);
|