← 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;