datewise/app/services/geocode.server.ts
metatroncubeswdev e56182e0e3 chore: initial Datewise app (rebranded from SlotSure prototype)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:49 -04:00

70 lines
2.9 KiB
TypeScript

/**
* Maps adapter (Google Maps Platform). Used only when a shop has radius or
* driving-distance zones; postal-code zones never call out.
*
* Failure policy: on timeout/error we return the address WITHOUT coordinates/driving
* distances. The engine then refuses distance-based zones ("couldn't verify your
* address") rather than accepting an unverified address.
*/
import type { LocationConfig } from "../engine/types";
import type { CustomerAddress } from "../engine/zones";
const KEY = process.env.GOOGLE_MAPS_API_KEY;
const TIMEOUT = 2500;
const cache = new Map<string, { at: number; v: CustomerAddress }>();
const TTL = 24 * 3600_000;
async function getJson(url: string): Promise<unknown> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), TIMEOUT);
try {
const r = await fetch(url, { signal: ctrl.signal });
if (!r.ok) throw new Error(`maps ${r.status}`);
return await r.json();
} finally {
clearTimeout(t);
}
}
export function needsMaps(locations: LocationConfig[]) {
return locations.some((l) => l.zones.some((z) => z.radiusKm != null || z.drivingKm != null));
}
export async function resolveAddress(
input: { postal?: string; address?: string; country?: string },
locations: LocationConfig[],
): Promise<CustomerAddress & { degraded?: boolean }> {
const base: CustomerAddress = { postal: input.postal };
if (!KEY || !needsMaps(locations)) return base;
const query = [input.address, input.postal, input.country].filter(Boolean).join(", ");
if (!query) return base;
const ck = `${query}|${locations.map((l) => l.id).join(",")}`;
const hit = cache.get(ck);
if (hit && Date.now() - hit.at < TTL) return hit.v;
try {
const geo = (await getJson(
`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${KEY}`,
)) as { status: string; results: { geometry: { location: { lat: number; lng: number } } }[] };
if (geo.status !== "OK" || !geo.results[0]) return base;
const { lat, lng } = geo.results[0].geometry.location;
const out: CustomerAddress = { ...base, lat, lng };
const driving = locations.filter((l) => l.lat != null && l.zones.some((z) => z.drivingKm != null));
if (driving.length) {
const dm = (await getJson(
`https://maps.googleapis.com/maps/api/distancematrix/json?origins=${driving
.map((l) => `${l.lat},${l.lng}`)
.join("|")}&destinations=${lat},${lng}&mode=driving&key=${KEY}`,
)) as { rows: { elements: { status: string; distance?: { value: number } }[] }[] };
out.drivingKm = {};
driving.forEach((l, i) => {
const el = dm.rows[i]?.elements[0];
out.drivingKm![l.id] = el?.status === "OK" && el.distance ? el.distance.value / 1000 : null;
});
}
cache.set(ck, { at: Date.now(), v: out });
return out;
} catch {
return { ...base, degraded: true };
}
}