/** * Reverse geocoding for spot/shop location tags * ───────────────────────────────────────────────────────────────────────────── * Turns a pin into `city` / `country` / `country_code` tags so the map AND the * community stats agree on where a spot is. This is the **single source of truth** * for the request, used by spot create/edit and shop create/edit. * * Two providers, tried in order, so a new spot is tagged by default: * 1. OpenStreetMap **Nominatim** (primary — matches existing data). * 2. **BigDataCloud** `reverse-geocode-client` (fallback — client-friendly, no * key). Nominatim's usage policy forbids per-viewer volume and can 429; a * single create/edit call is fine, but we don't want a failure to mean an * untagged spot, so we fall back. * * Best-effort by design — it **never throws**, so a geocoder outage can never * block a publish. If both providers fail the spot still publishes (with its * geohash); the stats' read-time derivation can still place it. * * ⚠️ Both providers see the caller's IP. Disclosed in `/privacy` → * "What leaves your device" → *Spot/shop geocoding*. The coordinates are the * **spot/shop pin**, never the device's live GPS. */ export interface GeocodedRegions { /** Best available human city/town/village/county name. */ city: string; /** Nominatim's display country name (e.g. "United Kingdom"). */ country: string; /** Region/state, used to build a readable location string. */ state: string; /** ISO-3166-1 alpha-2, uppercased (e.g. "GB"). The stable stats grouping key. */ countryCode: string; } const EMPTY: GeocodedRegions = { city: '', country: '', state: '', countryCode: '' }; const NOMINATIM_TIMEOUT_MS = 6000; const BIGDATA_TIMEOUT_MS = 8000; /** Primary: OpenStreetMap Nominatim. Never throws. */ async function geocodeNominatim(lat: number, lng: number): Promise { try { const res = await fetch( `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=10`, { headers: { 'Accept-Language': 'en', 'User-Agent': 'SkateSpots/1.0' }, signal: AbortSignal.timeout(NOMINATIM_TIMEOUT_MS), }, ); if (!res.ok) return EMPTY; const data = (await res.json()) as { address?: Record }; const addr = data.address ?? {}; return { city: addr.city ?? addr.town ?? addr.village ?? addr.county ?? '', country: addr.country ?? '', state: addr.state ?? '', countryCode: String(addr.country_code ?? '').trim().toUpperCase(), }; } catch { return EMPTY; } } /** * Fallback: BigDataCloud client-side reverse geocoding (no key, CORS `*`). * Also used directly by the stats' read-time derivation (`lib/derivedRegions`), * where Nominatim must NOT be used. Never throws. */ export async function geocodeBigDataCloud(lat: number, lng: number): Promise { try { const res = await fetch( `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lng}&localityLanguage=en`, { signal: AbortSignal.timeout(BIGDATA_TIMEOUT_MS) }, ); if (!res.ok) return EMPTY; const data = (await res.json()) as { city?: string; locality?: string; countryName?: string; countryCode?: string; principalSubdivision?: string; }; return { city: data.city || data.locality || '', country: data.countryName || '', state: data.principalSubdivision || '', countryCode: String(data.countryCode ?? '').trim().toUpperCase(), }; } catch { return EMPTY; } } /** * Reverse geocode a pin. Nominatim first, then BigDataCloud. Resolves to empty * strings only when BOTH providers fail. */ export async function reverseGeocodeRegions(lat: number, lng: number): Promise { const primary = await geocodeNominatim(lat, lng); if (primary.city || primary.country || primary.countryCode) return primary; return geocodeBigDataCloud(lat, lng); } /** Push the region tags onto an event tag list, skipping empty values. */ export function pushRegionTags(tags: string[][], geo: GeocodedRegions): void { if (geo.city) tags.push(['city', geo.city]); if (geo.country) tags.push(['country', geo.country]); if (geo.countryCode) tags.push(['country_code', geo.countryCode]); }