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,96 @@
|
||||
<?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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user