← 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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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 };
})();