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
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/** Fixed-window rate limiter backed by the database (works on shared hosting without Redis). */
final class RateLimit
{
/**
* @return array{allowed:bool, retryAfter:int}
*/
public static function hit(string $bucket, int $limit, int $windowSeconds): array
{
$pdo = Db::pdo();
$now = time();
$pdo->beginTransaction();
try {
$st = $pdo->prepare('SELECT hits, window_start FROM rate_limits WHERE bucket = ?');
$st->execute([$bucket]);
$row = $st->fetch();
if (!$row || ($now - (int)$row['window_start']) >= $windowSeconds) {
if (Db::isSqlite()) {
$pdo->prepare('INSERT OR REPLACE INTO rate_limits (bucket, hits, window_start) VALUES (?, 1, ?)')->execute([$bucket, $now]);
} else {
$pdo->prepare('REPLACE INTO rate_limits (bucket, hits, window_start) VALUES (?, 1, ?)')->execute([$bucket, $now]);
}
$pdo->commit();
return ['allowed' => true, 'retryAfter' => 0];
}
$hits = (int)$row['hits'] + 1;
$pdo->prepare('UPDATE rate_limits SET hits = ? WHERE bucket = ?')->execute([$hits, $bucket]);
$pdo->commit();
if ($hits > $limit) {
return ['allowed' => false, 'retryAfter' => max(1, $windowSeconds - ($now - (int)$row['window_start']))];
}
return ['allowed' => true, 'retryAfter' => 0];
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
Log::warn('Rate limit storage failed; allowing request', ['error' => $e->getMessage()]);
return ['allowed' => true, 'retryAfter' => 0];
}
}
/** Stable, non-reversible bucket key for an IP. */
public static function ipBucket(string $prefix, string $ip): string
{
return $prefix . ':' . substr(hash('sha256', $ip . '|' . (Config::get('HASH_SALT') ?? 'visit')), 0, 32);
}
}