← back to Homesonspec
apps/mobile/lib/api.ts
141 lines
/**
* Homes on Spec — API client
*
* All calls hit the LIVE homesonspec.com production backend.
* No auth required for browse/search/map endpoints.
*/
const BASE_URL = 'https://homesonspec.com';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Compact map marker as returned by GET /api/map */
export interface MapMarker {
/** home ID */
i: string;
/** latitude */
la: number;
/** longitude */
lo: number;
/** price (null if not set) */
p: number | null;
/** beds (nullable) */
b: number | null;
/** construction status */
s: 'MOVE_IN_READY' | 'UNDER_CONSTRUCTION' | 'PLANNED';
/** builder slug */
bl: string;
/** city + state string */
c: string;
}
export interface MapResponse {
markers: MapMarker[];
builders: { slug: string; name: string; count: number }[];
total: number;
}
/** Facets response shape (partial — extend as needed) */
export interface FacetsResponse {
constructionStatus: { value: string; count: number }[];
builder: { value: string; label: string; count: number }[];
beds: { value: number; count: number }[];
baths: { value: number; count: number }[];
homeType: { value: string; count: number }[];
state: { value: string; count: number }[];
priceBands: { label: string; min: number | null; max: number | null; count: number }[];
sqftBands: { label: string; min: number | null; max: number | null; count: number }[];
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function get<T>(path: string): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) {
throw new Error(`GET ${path} → ${res.status}`);
}
return res.json() as Promise<T>;
}
// ---------------------------------------------------------------------------
// Public API functions
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Shared /api/map index (for saved-home enrichment)
//
// homesonspec.com has NO per-home JSON detail endpoint (verified: /api/homes/:id
// 404s), so /api/map is the ONLY id-keyed structured source of price/city/status.
// We lazily build a shared id->marker index (10-min TTL) so the Map tab and the
// Saved-tab enrichment reuse a single parse of the large payload. The index is
// only built when something actually needs it — an app that never opens the Map
// or Saved tab never parses it.
// ---------------------------------------------------------------------------
let _mapIndex: { at: number; byId: Map<string, MapMarker> } | null = null;
const MAP_INDEX_TTL_MS = 10 * 60 * 1000;
function buildIndex(markers: MapMarker[]): Map<string, MapMarker> {
const byId = new Map<string, MapMarker>();
for (const m of markers) byId.set(m.i, m);
return byId;
}
/**
* Fetch all mappable homes with lat/lng for the native Map tab.
* Returns compact markers (terse keys — see MapMarker type).
* Warms the shared id index so a later enrichment lookup is free.
* Endpoint: GET /api/map
*/
export async function fetchMapMarkers(): Promise<MapResponse> {
const res = await get<MapResponse>('/api/map');
if (Array.isArray(res?.markers)) {
_mapIndex = { at: Date.now(), byId: buildIndex(res.markers) };
}
return res;
}
/**
* Look up a single home's map record by id, reusing the shared lazily-built
* /api/map index (10-min TTL). Best-effort: returns null on any failure or
* contract change — never throws (enrichment must not break the Saved tab).
*/
export async function getHomeById(id: string): Promise<MapMarker | null> {
try {
if (!_mapIndex || Date.now() - _mapIndex.at > MAP_INDEX_TTL_MS) {
const res = await get<MapResponse>('/api/map');
if (!Array.isArray(res?.markers)) return _mapIndex?.byId.get(id) ?? null;
_mapIndex = { at: Date.now(), byId: buildIndex(res.markers) };
}
return _mapIndex.byId.get(id) ?? null;
} catch {
return _mapIndex?.byId.get(id) ?? null;
}
}
/**
* Fetch search facets (builders, statuses, beds, price bands, etc.)
* Endpoint: GET /api/facets
*/
export async function fetchFacets(): Promise<FacetsResponse> {
return get<FacetsResponse>('/api/facets');
}
/**
* Deep-link to a specific home's detail page on homesonspec.com.
* Used from the Saved tab to open a saved home in the WebView.
*/
export function homeDetailUrl(homeId: string): string {
// Live PDP path is /homes/:id (plural). /home/:id 404s (verified Cycle 2).
return `${BASE_URL}/homes/${homeId}`;
}
/** Base URL for the WebView (Browse tab). */
export const WEB_BASE_URL = BASE_URL;