← back to Animals

public/js/geo.js

168 lines

// AnimalsDirectory — geolocation onload + "Near me" filter.
// Strategy:
//   1. Try cached coords from localStorage (24h TTL) — instant.
//   2. Try navigator.geolocation.getCurrentPosition — high accuracy if user allows.
//   3. Fall back to ipapi.co free tier (no key, 1k req/day) for a city-level estimate.
//   4. Expose the result via window.userGeo and dispatch a 'geo:ready' event.
//
// Pages with #geo-banner / #near-me-list will auto-update.

(function () {
  const CACHE_KEY = 'animals.geo.v1';
  const TTL_MS = 24 * 60 * 60 * 1000;

  function readCache() {
    try {
      const raw = localStorage.getItem(CACHE_KEY);
      if (!raw) return null;
      const j = JSON.parse(raw);
      if (Date.now() - j.t > TTL_MS) return null;
      return j;
    } catch { return null; }
  }
  function writeCache(geo) {
    try { localStorage.setItem(CACHE_KEY, JSON.stringify({ ...geo, t: Date.now() })); } catch {}
  }

  function setReady(geo) {
    window.userGeo = geo;
    writeCache(geo);
    document.dispatchEvent(new CustomEvent('geo:ready', { detail: geo }));
    renderBanner(geo);
    fetchNearMe(geo);
  }

  function renderBanner(geo) {
    const el = document.getElementById('geo-banner');
    if (!el) return;
    const where = geo.city ? `${geo.city}${geo.state ? ', ' + geo.state : ''}` : `${geo.lat.toFixed(2)}, ${geo.lng.toFixed(2)}`;
    el.innerHTML =
      `<span class="geo-pin">📍</span> ` +
      `<span>Showing results near <strong>${where}</strong> ` +
      `<small>(${geo.source})</small></span> ` +
      `<button type="button" class="geo-change">change</button>`;
    el.querySelector('.geo-change').addEventListener('click', promptManualLocation);
  }

  async function fetchNearMe(geo) {
    const list = document.getElementById('near-me-list');
    if (!list) return;
    try {
      const r = await fetch(`/api/near-me?lat=${geo.lat}&lng=${geo.lng}&radius_mi=25`);
      if (!r.ok) throw new Error('HTTP ' + r.status);
      const j = await r.json();
      if (!j.results || j.results.length === 0) {
        list.innerHTML = '<p class="empty">No nearby listings within 25 miles. Try widening the radius.</p>';
        return;
      }
      list.innerHTML = j.results.map(b =>
        `<a class="near-card" href="/clinic/${b.id}">
          <strong>${escapeHtml(b.name)}</strong>
          <small>${escapeHtml(b.category.replace(/_/g, ' '))} · ${b.distance_mi.toFixed(1)} mi · ${escapeHtml(b.city || '')}${b.state ? ', ' + escapeHtml(b.state) : ''}</small>
        </a>`
      ).join('');
    } catch (e) {
      list.innerHTML = '<p class="empty">Could not load nearby listings.</p>';
      console.warn('near-me fetch failed', e);
    }
  }

  function escapeHtml(s) {
    return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
  }

  function tryBrowserGeolocation() {
    return new Promise((resolve) => {
      if (!navigator.geolocation) return resolve(null);
      navigator.geolocation.getCurrentPosition(
        (pos) => resolve({
          lat: pos.coords.latitude, lng: pos.coords.longitude,
          accuracy: pos.coords.accuracy, source: 'gps'
        }),
        () => resolve(null),
        { enableHighAccuracy: true, timeout: 7000, maximumAge: 60000 }
      );
    });
  }

  async function tryIpFallback() {
    try {
      const r = await fetch('https://ipapi.co/json/', { headers: { accept: 'application/json' } });
      if (!r.ok) return null;
      const j = await r.json();
      if (!j.latitude || !j.longitude) return null;
      return {
        lat: parseFloat(j.latitude), lng: parseFloat(j.longitude),
        city: j.city, state: j.region_code, country: j.country, source: 'ip'
      };
    } catch { return null; }
  }

  async function reverseGeocode(geo) {
    if (geo.city) return geo;
    try {
      // Nominatim free, polite UA required; rate-limited 1 req/sec
      const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${geo.lat}&lon=${geo.lng}&zoom=10`, {
        headers: { 'accept': 'application/json' }
      });
      if (!r.ok) return geo;
      const j = await r.json();
      const a = j.address || {};
      return { ...geo,
        city: a.city || a.town || a.village || a.hamlet || a.county,
        state: (a.state_code || a.state || '').slice(0, 2).toUpperCase()
      };
    } catch { return geo; }
  }

  function promptManualLocation() {
    const v = prompt('Enter a city or ZIP (e.g., "Los Angeles, CA" or "10001"):');
    if (!v) return;
    fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(v + ', USA')}&limit=1`)
      .then(r => r.json())
      .then(arr => {
        if (!arr || !arr.length) return alert('Could not find that location.');
        const hit = arr[0];
        const parts = (hit.display_name || '').split(',').map(s => s.trim());
        setReady({
          lat: parseFloat(hit.lat), lng: parseFloat(hit.lon),
          city: parts[0], state: (parts[parts.length - 2] || '').slice(0, 2).toUpperCase(),
          source: 'manual'
        });
      });
  }

  async function init() {
    // Cached?
    const cached = readCache();
    if (cached) { setReady(cached); return; }

    // Try GPS (asks user permission)
    const gps = await tryBrowserGeolocation();
    if (gps) { setReady(await reverseGeocode(gps)); return; }

    // IP fallback
    const ip = await tryIpFallback();
    if (ip) { setReady(ip); return; }

    // Nothing worked — prompt manual
    const el = document.getElementById('geo-banner');
    if (el) {
      el.innerHTML = `<span class="geo-pin">📍</span> ` +
        `<span>Set your location to see nearby vets, groomers, shelters.</span> ` +
        `<button type="button" class="geo-change">set location</button>`;
      el.querySelector('.geo-change').addEventListener('click', promptManualLocation);
    }
  }

  // Auto-init when DOM is ready
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }

  // Public API
  window.AnimalsGeo = { promptManualLocation, init };
})();