Files
Waters ChurchandClaude Fable 5.1 78cd0cc550 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>
2026-09-22 13:22:26 -04:00

97 lines
3.9 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* US ZIP → coordinates for the manual campus search.
*
* GEOCODE_PROVIDER:
* zippopotam – https://api.zippopotam.us (free, no key) [default]
* nominatim – OpenStreetMap Nominatim (free, 1 req/s, requires identifying UA)
* google – Google Geocoding API (GEOCODE_API_KEY required)
*/
final class Geocode
{
/** @return array{lat:float,lng:float,city:?string,state:?string}|null */
public static function zip(string $zip): ?array
{
if (!preg_match('/^\d{5}$/', $zip)) {
return null;
}
$cacheKey = 'zip:' . $zip;
$cached = Db::cacheGet($cacheKey);
if ($cached !== null) {
$c = json_decode($cached, true);
return is_array($c) && $c ? $c : null;
}
$provider = strtolower(Config::get('GEOCODE_PROVIDER', 'zippopotam') ?? 'zippopotam');
$result = null;
try {
$result = match ($provider) {
'nominatim' => self::nominatim($zip),
'google' => self::google($zip),
default => self::zippopotam($zip),
};
} catch (Throwable $e) {
Log::warn('Geocode provider failed', ['provider' => $provider, 'error' => $e->getMessage()]);
}
// ZIP centroids do not change: cache for 30 days (failures for 10 minutes).
Db::cacheSet($cacheKey, json_encode($result ?? []), $result ? 30 * 86400 : 600);
return $result;
}
private static function zippopotam(string $zip): ?array
{
$base = rtrim(Config::get('GEOCODE_BASE_URL_OVERRIDE') ?: 'https://api.zippopotam.us', '/'); // override is for the test-suite only
$r = Http::request('GET', "$base/us/$zip", null, ['Accept' => 'application/json'], 6);
$j = $r['json'];
if ($r['status'] !== 200 || !is_array($j) || empty($j['places'][0])) {
return null;
}
$p = $j['places'][0];
return self::result((float)$p['latitude'], (float)$p['longitude'], $p['place name'] ?? null, $p['state abbreviation'] ?? null);
}
private static function nominatim(string $zip): ?array
{
$r = Http::request('GET', "https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&countrycodes=us&postalcode=$zip", null, ['Accept' => 'application/json'], 6);
$j = $r['json'];
if ($r['status'] !== 200 || !is_array($j) || empty($j[0]['lat'])) {
return null;
}
return self::result((float)$j[0]['lat'], (float)$j[0]['lon'], null, null);
}
private static function google(string $zip): ?array
{
$key = Config::require('GEOCODE_API_KEY');
$r = Http::request('GET', "https://maps.googleapis.com/maps/api/geocode/json?components=postal_code:$zip|country:US&key=" . rawurlencode($key), null, [], 6);
$j = $r['json'];
if ($r['status'] !== 200 || !is_array($j) || ($j['status'] ?? '') !== 'OK') {
return null;
}
$loc = $j['results'][0]['geometry']['location'] ?? null;
if (!$loc) {
return null;
}
$city = null;
$state = null;
foreach ($j['results'][0]['address_components'] ?? [] as $c) {
if (in_array('locality', $c['types'] ?? [], true)) {
$city = $c['long_name'];
}
if (in_array('administrative_area_level_1', $c['types'] ?? [], true)) {
$state = $c['short_name'];
}
}
return self::result((float)$loc['lat'], (float)$loc['lng'], $city, $state);
}
private static function result(float $lat, float $lng, ?string $city, ?string $state): ?array
{
if (($lat === 0.0 && $lng === 0.0) || abs($lat) > 90 || abs($lng) > 180) {
return null;
}
return ['lat' => round($lat, 4), 'lng' => round($lng, 4), 'city' => $city ? Validator::clean($city, 60) : null, 'state' => $state ? Validator::clean($state, 20) : null];
}
}