import type { MaterialCommunityIcons } from "@expo/vector-icons"; // Google Places (New) Nearby Search — powers the "nearby mall / hospital / // pharmacy / restaurant" destination chips on the home screen. Reuses the same // API key and header pattern as the autocomplete in components/google-text-input. import { tr } from "@/lib/i18n"; import { haversine } from "@/lib/utils"; import type { NearbyPlace } from "@/types/type"; const googleApiKey = process.env.EXPO_PUBLIC_GOOGLE_API_KEY!; // The four POI categories surfaced as quick destination chips. Each maps to a // Google Places (New) `includedTypes` value. export type PoiCategory = { id: "mall" | "hospital" | "pharmacy" | "restaurant"; /** i18n key for the chip label. */ labelKey: string; /** MaterialCommunityIcons glyph name. Typed so a bad name fails the build * rather than warning at runtime and rendering nothing. */ icon: React.ComponentProps["name"]; googleType: string; }; export const POI_CATEGORIES: PoiCategory[] = [ { id: "mall", labelKey: "pois.mall", icon: "storefront", googleType: "shopping_mall", }, { id: "hospital", labelKey: "pois.hospital", icon: "hospital", googleType: "hospital", }, { id: "pharmacy", labelKey: "pois.pharmacy", icon: "pill", googleType: "pharmacy", }, { id: "restaurant", labelKey: "pois.restaurant", icon: "silverware-fork-knife", googleType: "restaurant", }, ]; const DEFAULT_RADIUS_M = 4000; // Nearby Search ranks by popularity unless told otherwise, which put a mall a // 29-minute drive away ahead of one 6 minutes away. Rank by distance instead, // then re-rank the closest few by their actual driving route: straight-line // distance is a poor proxy in Lebanon's mountain terrain, where a place 2.9 km // away as the crow flies can be a 17.9 km drive around a valley. Each candidate // costs one Directions call, so keep the set small. const ROUTE_CANDIDATES = 3; type DrivingRoute = { distanceMeters: number; durationSeconds: number }; // Road distance/time between two points. Returns null when Google finds no // route (ZERO_RESULTS) or the request fails, so callers can fall back. const fetchDrivingRoute = async ( from: { latitude: number; longitude: number }, to: { latitude: number; longitude: number }, ): Promise => { try { const res = await fetch( `https://maps.googleapis.com/maps/api/directions/json?origin=${from.latitude},${from.longitude}&destination=${to.latitude},${to.longitude}&mode=driving&key=${googleApiKey}`, ); const data = await res.json(); const leg = data.routes?.[0]?.legs?.[0]; if (!leg) return null; return { distanceMeters: leg.distance.value, durationSeconds: leg.duration.value, }; } catch (error) { console.log("[PLACES_ROUTE]: ", error); return null; } }; // Searches for the nearest place of `googleType` around (latitude, longitude) // and returns it as a NearbyPlace carrying its real driving distance and time. // "Nearest" means nearest by road, not by straight line. Returns null when no // place of that type is found nearby — the chip then shows an empty state // rather than a broken one. export const searchNearby = async ( googleType: string, { latitude, longitude, radiusM = DEFAULT_RADIUS_M, }: { latitude: number; longitude: number; radiusM?: number }, ): Promise => { try { const res = await fetch( "https://places.googleapis.com/v1/places:searchNearby", { method: "POST", headers: { "Content-Type": "application/json", "X-Goog-Api-Key": googleApiKey, "X-Goog-FieldMask": "places.displayName,places.formattedAddress,places.location,places.id", }, body: JSON.stringify({ includedTypes: [googleType], languageCode: "en", regionCode: "lb", rankPreference: "DISTANCE", maxResultCount: ROUTE_CANDIDATES, locationRestriction: { circle: { center: { latitude, longitude }, radius: radiusM, }, }, }), }, ); const data = await res.json(); const candidates = ((data.places ?? []) as Record[]) .map((place) => { const lat = place.location?.latitude as number; const lng = place.location?.longitude as number; if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; return { name: (place.displayName?.text as string) ?? tr("pois.nearbyPlace"), address: (place.formattedAddress as string) ?? "", latitude: lat, longitude: lng, distanceMeters: haversine(latitude, longitude, lat, lng), }; }) .filter( (candidate): candidate is NonNullable => candidate !== null, ); if (!candidates.length) return null; const routes = await Promise.all( candidates.map((candidate) => fetchDrivingRoute({ latitude, longitude }, candidate), ), ); const routed = candidates .map((candidate, index) => ({ candidate, route: routes[index] })) .filter( ( entry, ): entry is { candidate: (typeof candidates)[number]; route: DrivingRoute; } => entry.route !== null, ); // Every candidate unreachable, or Directions failed for all of them: show // the straight-line nearest rather than dropping the chip entirely. if (!routed.length) return candidates[0]; const best = routed.reduce((shortest, entry) => entry.route.distanceMeters < shortest.route.distanceMeters ? entry : shortest, ); return { ...best.candidate, routeDistanceMeters: best.route.distanceMeters, routeDurationSeconds: best.route.durationSeconds, }; } catch (error) { console.log("[PLACES_NEARBY]: ", error); return null; } };