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:
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Server-side validation and sanitization of the Plan Your Visit payload.
|
||||
* Mirrors src/lib/schema.ts. The server is authoritative.
|
||||
*/
|
||||
final class Validator
|
||||
{
|
||||
public const CHILD_AGE_RANGES = ['0-2', '3-5', '6-10', '11-13', '14-18'];
|
||||
private const ATTR_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'gclid', 'gbraid', 'wbraid', 'fbclid', 'ttclid', 'twclid', 'variant', 'landing_url', 'referrer', 'first_seen_at'];
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $in
|
||||
* @return array{data:array<string,mixed>, errors:array<string,string>}
|
||||
*/
|
||||
public static function planVisit(array $in): array
|
||||
{
|
||||
$errors = [];
|
||||
$d = [];
|
||||
|
||||
$d['submission_id'] = self::str($in, 'submissionId', 36);
|
||||
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $d['submission_id'])) {
|
||||
$errors['submissionId'] = 'Invalid submission id.';
|
||||
}
|
||||
|
||||
$d['first_name'] = self::str($in, 'firstName', 60);
|
||||
if ($d['first_name'] === '') {
|
||||
$errors['firstName'] = 'Please tell us your first name.';
|
||||
}
|
||||
$d['last_name'] = self::str($in, 'lastName', 60) ?: null;
|
||||
|
||||
$email = strtolower(self::str($in, 'email', 254));
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !str_contains($email, '.')) {
|
||||
$errors['email'] = "That email doesn't look right.";
|
||||
}
|
||||
$d['email'] = $email;
|
||||
|
||||
$d['email_consent'] = (bool)($in['emailConsent'] ?? false);
|
||||
$d['sms_consent'] = (bool)($in['smsConsent'] ?? false);
|
||||
|
||||
$phoneRaw = self::str($in, 'phone', 30);
|
||||
$d['phone_e164'] = null;
|
||||
if ($phoneRaw !== '') {
|
||||
$e164 = Phone::normalizeNanp($phoneRaw);
|
||||
if ($e164 === null) {
|
||||
$errors['phone'] = "That phone number doesn't look right.";
|
||||
} else {
|
||||
$d['phone_e164'] = $e164;
|
||||
}
|
||||
}
|
||||
if ($d['sms_consent'] && $d['phone_e164'] === null && !isset($errors['phone'])) {
|
||||
$errors['phone'] = 'Add a mobile number so we can text you, or uncheck texting.';
|
||||
}
|
||||
|
||||
$d['campus_id'] = self::str($in, 'campusId', 40);
|
||||
if (!in_array($d['campus_id'], Campuses::openIds(), true)) {
|
||||
$errors['campusId'] = 'Please choose a campus.';
|
||||
}
|
||||
|
||||
$visitDate = self::str($in, 'visitDate', 10);
|
||||
$d['visit_date'] = null;
|
||||
if ($visitDate !== '') {
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $visitDate) || !strtotime($visitDate)) {
|
||||
$errors['visitDate'] = 'Use the date picker.';
|
||||
} elseif ($visitDate < gmdate('Y-m-d', time() - 86400)) { // allow one day of timezone slack
|
||||
$errors['visitDate'] = 'Pick today or a future date.';
|
||||
} else {
|
||||
$d['visit_date'] = $visitDate;
|
||||
}
|
||||
}
|
||||
|
||||
$d['service_time'] = self::str($in, 'serviceTime', 80) ?: null;
|
||||
|
||||
$d['adults'] = self::intOrNull($in, 'adults', 1, 20);
|
||||
$d['children'] = self::intOrNull($in, 'children', 0, 10);
|
||||
|
||||
$ranges = $in['childAgeRanges'] ?? [];
|
||||
$clean = [];
|
||||
if (is_array($ranges)) {
|
||||
foreach ($ranges as $r) {
|
||||
if (is_string($r) && in_array($r, self::CHILD_AGE_RANGES, true) && !in_array($r, $clean, true)) {
|
||||
$clean[] = $r;
|
||||
}
|
||||
}
|
||||
}
|
||||
$d['child_age_ranges'] = $clean ? implode(',', $clean) : null;
|
||||
|
||||
$d['questions'] = self::str($in, 'questions', 1000) ?: null;
|
||||
|
||||
$d['consent_version'] = self::str($in, 'consentVersion', 40);
|
||||
if ($d['consent_version'] === '') {
|
||||
$errors['consentVersion'] = 'Missing consent version.';
|
||||
}
|
||||
|
||||
// Anti-spam: honeypot + minimum time on form
|
||||
$d['honeypot'] = self::str($in, 'website', 200);
|
||||
$startedAt = (int)($in['startedAt'] ?? 0);
|
||||
$d['elapsed_ms'] = $startedAt > 0 ? max(0, (int)(microtime(true) * 1000) - $startedAt) : 0;
|
||||
|
||||
// Attribution: whitelist keys, cap lengths, strip control chars
|
||||
$attr = [];
|
||||
if (isset($in['attribution']) && is_array($in['attribution'])) {
|
||||
foreach ($in['attribution'] as $k => $v) {
|
||||
if (is_string($k) && is_string($v) && in_array($k, self::ATTR_KEYS, true)) {
|
||||
$max = in_array($k, ['landing_url', 'referrer'], true) ? 2000 : 200;
|
||||
$attr[$k] = self::clean($v, $max);
|
||||
}
|
||||
}
|
||||
}
|
||||
$d['attribution'] = $attr;
|
||||
$d['turnstile_token'] = is_string($in['turnstileToken'] ?? null) ? substr($in['turnstileToken'], 0, 4000) : null;
|
||||
|
||||
return ['data' => $d, 'errors' => $errors];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $in */
|
||||
private static function str(array $in, string $key, int $max): string
|
||||
{
|
||||
$v = $in[$key] ?? '';
|
||||
if (!is_string($v)) {
|
||||
return '';
|
||||
}
|
||||
return self::clean($v, $max);
|
||||
}
|
||||
|
||||
public static function clean(string $v, int $max): string
|
||||
{
|
||||
$v = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $v) ?? '';
|
||||
$v = trim($v);
|
||||
if (!mb_check_encoding($v, 'UTF-8')) {
|
||||
$v = mb_convert_encoding($v, 'UTF-8', 'UTF-8');
|
||||
}
|
||||
return mb_substr($v, 0, $max);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $in */
|
||||
private static function intOrNull(array $in, string $key, int $min, int $max): ?int
|
||||
{
|
||||
if (!isset($in[$key]) || $in[$key] === '' || $in[$key] === null) {
|
||||
return null;
|
||||
}
|
||||
if (!is_numeric($in[$key])) {
|
||||
return null;
|
||||
}
|
||||
$n = (int)$in[$key];
|
||||
return ($n < $min || $n > $max) ? null : $n;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user