diff --git a/.checkpoints/checkpoint-20260412-140915.diff b/.checkpoints/checkpoint-20260412-140915.diff new file mode 100644 index 0000000..991b5b2 --- /dev/null +++ b/.checkpoints/checkpoint-20260412-140915.diff @@ -0,0 +1,465 @@ +diff --git a/README.md b/README.md +index c0b38f0..3d53617 100644 +--- a/README.md ++++ b/README.md +@@ -98,6 +98,16 @@ An Expo React Native mobile application that helps disabled students have more a + + 6. Copy the tunnel URL for the next steps + ++ 7. Sync both app and backend env values in one command: ++ ```bash ++ pnpm devtunnel:set https://your-id-54321.usw3.devtunnels.ms ++ ``` ++ ++ 8. Verify the tunnel actually reaches your backend routes: ++ ```bash ++ pnpm devtunnel:check ++ ``` ++ + **Configure Google Cloud Console:** + + 1. In [Google Cloud Console](https://console.cloud.google.com/apis/credentials), edit your Web OAuth Client +@@ -110,19 +120,7 @@ An Expo React Native mobile application that helps disabled students have more a + https://w5w3c6hf-54321.usw3.devtunnels.ms/api/auth/callback/google + ``` + +- **Update Environment Files:** +- +- **`.env` (Mobile App)** +- ```bash +- EXPO_PUBLIC_API_URL=https://w5w3c6hf-54321.usw3.devtunnels.ms +- ``` +- +- **`server/.env` (Backend)** +- ```bash +- BETTER_AUTH_URL=https://w5w3c6hf-54321.usw3.devtunnels.ms +- ``` +- +- **Note:** The tunnel URL changes sometimes, so if it changes, you'll need to update Google Cloud Console and your env files. ++ **Note:** The tunnel URL changes sometimes. Re-run `pnpm devtunnel:set ` whenever it changes. + + ## 🏃‍♂️ Running the App + +@@ -146,6 +144,9 @@ pnpm android + + ```bash + cd server ++pnpm setup:local ++# Optional first-time data load only: ++# pnpm seed:local + pnpm dev + ``` + +diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx +index 3475a89..25ba4d1 100644 +--- a/app/(tabs)/index.tsx ++++ b/app/(tabs)/index.tsx +@@ -27,7 +27,7 @@ import { + LocationDetailsBottomSheet, + type LocationDetailsBottomSheetRef, + } from "../../components/LocationDetailsBottomSheet"; +-import { getPlaceDetails } from "~/utils/googlePlaces"; ++import { getPlaceDetails, searchPlaces } from "~/utils/googlePlaces"; + + export default function Home() { + // hooks +@@ -292,10 +292,25 @@ export default function Home() { + // Close search + setIsSearchActive(false); + setSearchQuery(""); +- ++ ++ // Resolve missing place_id for recent/manual locations so recenter still works. ++ let resolvedPlaceId = location.place_id; ++ if (!resolvedPlaceId) { ++ const primaryQuery = [location.name, location.address].filter(Boolean).join(" "); ++ const fallbackQuery = location.name; ++ ++ const primaryResults = await searchPlaces(primaryQuery); ++ resolvedPlaceId = primaryResults[0]?.place_id; ++ ++ if (!resolvedPlaceId && fallbackQuery) { ++ const fallbackResults = await searchPlaces(fallbackQuery); ++ resolvedPlaceId = fallbackResults[0]?.place_id; ++ } ++ } ++ + // Fetch full place details +- if (location.place_id) { +- const placeDetails = await getPlaceDetails(location.place_id); ++ if (resolvedPlaceId) { ++ const placeDetails = await getPlaceDetails(resolvedPlaceId); + + if (placeDetails) { + const matchingBuilding = findCampusBuildingFeature( +@@ -328,6 +343,8 @@ export default function Home() { + locationBottomSheetRef.current?.present(placeDetails); + } + } ++ } else { ++ console.error("Could not resolve place_id for selected location", location); + } + }; + +diff --git a/components/POIBottomSheet.tsx b/components/POIBottomSheet.tsx +index 8287616..84fa85a 100644 +--- a/components/POIBottomSheet.tsx ++++ b/components/POIBottomSheet.tsx +@@ -66,6 +66,33 @@ const getCardinalLabelFromNeighbors = (entrance: any, neighbors: any[]): string + return "Northwest Entrance"; + }; + ++const normalizeText = (value?: string | null) => ++ (value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); ++ ++const extractBuildingAbbr = (value?: string | null): string | null => { ++ if (!value) return null; ++ ++ const parenMatch = value.match(/\(([A-Za-z0-9]{2,6})\)/); ++ if (parenMatch?.[1]) return parenMatch[1].toUpperCase(); ++ ++ const leadingCodeMatch = value.match(/^([A-Za-z0-9]{2,6})\b/); ++ if (leadingCodeMatch?.[1]) return leadingCodeMatch[1].toUpperCase(); ++ ++ return null; ++}; ++ ++const isEntranceInsideBuilding = (entrance: any, buildingFeature: any): boolean => { ++ const coords = entrance?.location_geojson?.coordinates; ++ const polygonCoords = buildingFeature?.geometry?.coordinates; ++ ++ if (!coords || !polygonCoords) return false; ++ ++ const [lng, lat] = coords; ++ const point = { type: "Point", coordinates: [lng, lat] }; ++ ++ return turf.booleanPointInPolygon(point as any, buildingFeature as any); ++}; ++ + interface POIBottomSheetProps { + ref: ForwardedRef; + allPOIs: any[]; +@@ -94,7 +121,7 @@ const POIContent = ({ poi, allPOIs }: POIContentProps) => { + }; + + const getBuildingAbbr = (str: string) => { +- return str ? str.substring(1, 4).toUpperCase() : ""; ++ return extractBuildingAbbr(str) ?? ""; + }; + + const findBuildingByAbbreviation = (abbreviation: string) => { +@@ -116,18 +143,33 @@ const POIContent = ({ poi, allPOIs }: POIContentProps) => { + + useEffect(() => { + const currentAbbr = +- getBuildingAbbr(metadata.bld_name) ?? getBuildingAbbr(metadata.name); ++ getBuildingAbbr(metadata.bld_name) || getBuildingAbbr(metadata.name); ++ const currentBuildingName = normalizeText(configBuildingName(metadata.bld_name)); ++ const fallbackName = normalizeText(metadata.name); + + if (currentAbbr && allPOIs.length) { + const matched = allPOIs.filter((p) => { + if (p.poi_type !== "accessible_entrance") return false; ++ ++ const entranceAbbr = ++ getBuildingAbbr(p.metadata?.bld_name) || getBuildingAbbr(p.metadata?.name); ++ const entranceName = normalizeText(p.metadata?.bld_name || p.metadata?.name); ++ + return ( +- getBuildingAbbr(p.metadata?.bld_name) === currentAbbr || +- getBuildingAbbr(p.metadata?.name) === currentAbbr ++ (currentAbbr && entranceAbbr === currentAbbr) || ++ (buildingFeature ? isEntranceInsideBuilding(p, buildingFeature) : false) || ++ (!!currentBuildingName && entranceName.includes(currentBuildingName)) || ++ (!!fallbackName && entranceName.includes(fallbackName)) + ); + }); + setEntrances(matched); + setSelectedEntrance(matched[0]?.id?.toString() ?? ""); ++ } else if (allPOIs.length && buildingFeature) { ++ const matchedByGeometry = allPOIs.filter( ++ (p) => p.poi_type === "accessible_entrance" && isEntranceInsideBuilding(p, buildingFeature), ++ ); ++ setEntrances(matchedByGeometry); ++ setSelectedEntrance(matchedByGeometry[0]?.id?.toString() ?? ""); + } else { + setEntrances([]); + } +diff --git a/package.json b/package.json +index 0fdd3f8..99db926 100644 +--- a/package.json ++++ b/package.json +@@ -10,6 +10,8 @@ + "prebuild": "expo prebuild", + "ios": "expo run:ios", + "android": "adb reverse tcp:54321 tcp:54321 && expo run:android", ++ "devtunnel:set": "node scripts/devtunnel.mjs set", ++ "devtunnel:check": "node scripts/devtunnel.mjs check", + "web": "expo start --web", + "lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"", + "format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write" +diff --git a/server/package.json b/server/package.json +index d867a54..78c7451 100644 +--- a/server/package.json ++++ b/server/package.json +@@ -9,6 +9,9 @@ + "test": "vitest", + "gen": "drizzle-kit generate", + "types": "wrangler types", ++ "migrate:local": "wrangler d1 migrations apply mobilize-db --local", ++ "seed:local": "wrangler d1 execute mobilize-db --local --file=./seed.sql", ++ "setup:local": "pnpm migrate:local", + "seed": "wrangler d1 execute mobilize-db --local --file=./seed.sql && curl http://localhost:54321/cdn-cgi/handler/scheduled", + "migrate": "wrangler d1 migrations apply mobilize-db" + }, +@@ -27,5 +30,10 @@ + "better-auth": "^1.3.34", + "drizzle-orm": "^0.44.7", + "hono": "^4.10.4" ++ }, ++ "pnpm": { ++ "onlyBuiltDependencies": [ ++ "workerd" ++ ] + } + } +diff --git a/server/pnpm-workspace.yaml b/server/pnpm-workspace.yaml +index cb8238f..34a366d 100644 +--- a/server/pnpm-workspace.yaml ++++ b/server/pnpm-workspace.yaml +@@ -1,3 +1,4 @@ + onlyBuiltDependencies: ++ - workerd + - esbuild + - unrs-resolver +diff --git a/server/src/index.ts b/server/src/index.ts +index eb9a1f6..b0cc31a 100644 +--- a/server/src/index.ts ++++ b/server/src/index.ts +@@ -4,6 +4,7 @@ import { drizzle } from "drizzle-orm/d1"; + import { eq } from "drizzle-orm"; + import * as schema from "./db/schema"; + import { createAuth } from "./auth"; ++import { syncPOIs } from "./scheduled/poi-sync"; + + type Bindings = { + mobilize_db: D1Database; +@@ -223,4 +224,13 @@ app.get("/api/me", async (c) => { + return c.json({ user: session.user }); + }); + +-export default app; +\ No newline at end of file ++export default { ++ fetch: app.fetch, ++ // Scheduled handler for cron triggers ++ async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise { ++ console.log(`Cron trigger fired at ${new Date(event.scheduledTime).toISOString()}`); ++ ++ // Run the POI sync ++ ctx.waitUntil(syncPOIs(env)); ++ }, ++}; +\ No newline at end of file +diff --git a/tsconfig.json b/tsconfig.json +index 5507e78..a7c1d04 100644 +--- a/tsconfig.json ++++ b/tsconfig.json +@@ -4,6 +4,7 @@ + "strict": true, + "strictNullChecks": true, + "jsx": "react-jsx", ++ "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "~/*": ["*"] +diff --git a/utils/api-client.ts b/utils/api-client.ts +index d59aca6..5181f6e 100644 +--- a/utils/api-client.ts ++++ b/utils/api-client.ts +@@ -22,16 +22,57 @@ class ApiClient { + const url = `${this.baseUrl}${endpoint}`; + + try { ++ const headers: Record = { ++ Accept: "application/json", ++ ...(options?.headers as Record | undefined), ++ }; ++ ++ // Only send JSON content-type when we actually send a body. ++ if (options?.body && !headers["Content-Type"]) { ++ headers["Content-Type"] = "application/json"; ++ } ++ + const response = await fetch(url, { + ...options, +- headers: { +- "Content-Type": "application/json", +- ...options?.headers, +- }, ++ headers, + }); + ++ const contentType = response.headers.get("content-type") || ""; ++ const isJson = contentType.includes("application/json"); ++ + if (!response.ok) { +- throw new Error(`API Error: ${response.status} ${response.statusText}`); ++ const bodyText = await response.text(); ++ const tunnelAuthRedirect = ++ response.status === 302 && ++ (response.headers.get("location") || "").includes( ++ "tunnels.api.visualstudio.com/auth", ++ ); ++ const tunnelAuthPage = bodyText.includes("tunnels.api.visualstudio.com/auth"); ++ ++ if (tunnelAuthRedirect || tunnelAuthPage) { ++ throw new Error( ++ "Dev tunnel is not public (or requires sign-in). Set forwarded port 54321 visibility to Public and retry.", ++ ); ++ } ++ ++ throw new Error( ++ `API Error: ${response.status} ${response.statusText} ${bodyText.slice(0, 120)}`, ++ ); ++ } ++ ++ if (!isJson) { ++ const bodyText = await response.text(); ++ const tunnelAuthPage = bodyText.includes("tunnels.api.visualstudio.com/auth"); ++ ++ if (tunnelAuthPage) { ++ throw new Error( ++ "Dev tunnel is returning an auth page instead of API JSON. Set forwarded port 54321 visibility to Public.", ++ ); ++ } ++ ++ throw new Error( ++ `Expected JSON from ${url}, but received '${contentType || "unknown"}'.`, ++ ); + } + + return await response.json(); +@@ -171,6 +212,7 @@ class ApiClient { + return rows; + } catch (err: any) { + console.error("UH OHHH UNHANDLED PARSING ERROR", err.message); ++ return []; + } + + +diff --git a/utils/googlePlaces.ts b/utils/googlePlaces.ts +index 9787435..f9a790e 100644 +--- a/utils/googlePlaces.ts ++++ b/utils/googlePlaces.ts +@@ -1,3 +1,6 @@ ++import { Platform } from "react-native"; ++import Constants from "expo-constants"; ++ + const GOOGLE_PLACES_API_KEY = process.env.EXPO_PUBLIC_GOOGLE_PLACES_API_KEY; + const PLACES_API_BASE_URL = "https://places.googleapis.com/v1"; + +@@ -8,6 +11,51 @@ const UT_AUSTIN_LOCATION = { + }; + const SEARCH_RADIUS = 2000; // 2km radius around UT campus + ++const getPlacesHeaders = () => { ++ const headers: Record = { ++ "Content-Type": "application/json", ++ "X-Goog-Api-Key": GOOGLE_PLACES_API_KEY || "", ++ }; ++ ++ if (Platform.OS === "ios") { ++ const bundleIdentifier = ++ Constants.expoConfig?.ios?.bundleIdentifier || ++ Constants.manifest2?.extra?.expoClient?.iosBundleIdentifier; ++ ++ if (bundleIdentifier) { ++ headers["X-Ios-Bundle-Identifier"] = bundleIdentifier; ++ } ++ } ++ ++ return headers; ++}; ++ ++const logPlacesError = (label: string, data: any) => { ++ const reason = data?.error?.details?.[0]?.reason; ++ ++ if (data?.error?.status === "PERMISSION_DENIED") { ++ console.error( ++ `${label}: PERMISSION_DENIED. Key restrictions likely block this client.`, ++ { ++ code: data?.error?.code, ++ status: data?.error?.status, ++ message: data?.error?.message, ++ reason, ++ }, ++ ); ++ ++ if (reason === "API_KEY_IOS_APP_BLOCKED") { ++ console.error( ++ "Google key is iOS-app restricted and rejected this request. Confirm bundle identifier restriction matches this app, or use an unrestricted/dev key for REST calls.", ++ ); ++ } ++ ++ return; ++ } ++ ++ console.error(label, data); ++}; ++ + // Types for Google Places API (New) responses + export interface PlaceAutocompletePrediction { + place_id: string; +@@ -63,10 +111,7 @@ export const searchPlaces = async ( + `${PLACES_API_BASE_URL}/places:autocomplete`, + { + method: "POST", +- headers: { +- "Content-Type": "application/json", +- "X-Goog-Api-Key": GOOGLE_PLACES_API_KEY, +- }, ++ headers: getPlacesHeaders(), + body: JSON.stringify({ + input: query, + locationBias: { +@@ -96,7 +141,7 @@ export const searchPlaces = async ( + }, + })); + } else { +- console.error("Places Autocomplete error:", data); ++ logPlacesError("Places Autocomplete error", data); + return []; + } + } catch (error) { +@@ -127,9 +172,6 @@ export const getPlaceDetails = async ( + "displayName", + "formattedAddress", + "location", +- "rating", +- "userRatingCount", +- "currentOpeningHours", + "photos", + "types", + ].join(","); +@@ -138,10 +180,7 @@ export const getPlaceDetails = async ( + `${PLACES_API_BASE_URL}/places/${placeId}?fields=${encodeURIComponent(fieldMask)}`, + { + method: "GET", +- headers: { +- "Content-Type": "application/json", +- "X-Goog-Api-Key": GOOGLE_PLACES_API_KEY, +- }, ++ headers: getPlacesHeaders(), + } + ); + +@@ -174,7 +213,7 @@ export const getPlaceDetails = async ( + types: data.types, + }; + } else { +- console.error("Place Details error:", data); ++ logPlacesError("Place Details error", data); + return null; + } + } catch (error) { diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..cc8df9d --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +node-linker=hoisted \ No newline at end of file diff --git a/.npmrc 2 b/.npmrc 2 new file mode 100644 index 0000000..cc8df9d --- /dev/null +++ b/.npmrc 2 @@ -0,0 +1 @@ +node-linker=hoisted \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..71d7286 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,36 @@ +{ + "deno.enablePaths": ["supabase/functions"], + "deno.lint": true, + "deno.unstable": [ + "bare-node-builtins", + "byonm", + "sloppy-imports", + "unsafe-proto", + "webgpu", + "broadcast-channel", + "worker-options", + "cron", + "kv", + "ffi", + "fs", + "http", + "net" + ], + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index bf004ef..06fc082 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -2,7 +2,7 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; import * as turf from "@turf/turf"; import { Stack } from "expo-router"; -import { useCallback, useMemo, useRef, useState, useEffect } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { View } from "react-native"; import MapView, { Polygon, Marker, LatLng } from "react-native-maps"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -19,20 +19,22 @@ import { useInsertAvoidanceArea, } from "~/utils/api-hooks"; import useMapIcons from "~/utils/useMapIcons"; +import buildingsData from "../../assets/geojson/buildings_simple.json"; import { SearchBar } from "~/components/SearchBar"; import { SearchDropdown } from "~/components/SearchDropdown"; import { LocationDetailsBottomSheet, type LocationDetailsBottomSheetRef, -} from "~/components/LocationDetailsBottomSheet"; -import { searchPlaces, getPlaceDetails } from "~/utils/googlePlaces"; +} from "../../components/LocationDetailsBottomSheet"; +import { getPlaceDetails, searchPlaces } from "~/utils/googlePlaces"; export default function Home() { // hooks const insets = useSafeAreaInsets(); const mapIcons = useMapIcons(); const bottomTabBarHeight = useBottomTabBarHeight(); + const mapRef = useRef(null); const avoidanceAreaBottomSheetRef = useRef(null); const poiBottomSheetRef = useRef(null); const bottomSheetRef = useRef(null); @@ -46,7 +48,7 @@ export default function Home() { const [zoomLevel, setZoomLevel] = useState(15); // Minimum zoom level to show POIs (higher = more zoomed in) - const MIN_ZOOM_FOR_POIS = 16; + const MIN_ZOOM_FOR_POIS = 15; const [isSearchActive, setIsSearchActive] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -56,20 +58,56 @@ export default function Home() { const { data: POIs } = usePOIs(); const { mutateAsync: insertAvoidanceArea } = useInsertAvoidanceArea(); - const testGooglePlaces = async () => { - console.log("Testing Google Places..."); - const results = await searchPlaces("Texas Global"); - console.log("Search results:", results); - - if (results.length > 0) { - const details = await getPlaceDetails(results[0].place_id); - console.log("Place details:", details); + const findCampusBuildingFeature = ( + latitude: number, + longitude: number, + placeName?: string, + placeAddress?: string, + ) => { + const point = turf.point([longitude, latitude]); + const buildingsAny = buildingsData as any; + const features: any[] = buildingsAny.features ?? []; + + const polygonMatch = features.find((feature) => + feature?.geometry && turf.booleanPointInPolygon(point, feature), + ); + + if (polygonMatch) { + return polygonMatch; } + + // Only trust strict polygon containment. + // If we cannot confidently match, fall back to the generic location sheet. + return null; }; - useEffect(() => { - testGooglePlaces(); - }, []); + const buildPoiFromCampusBuilding = ( + placeDetails: any, + buildingFeature: any, + ) => { + const buildingAbbr = buildingFeature?.properties?.Building_Abbr; + const buildingName = buildingFeature?.properties?.Description; + + if (!buildingAbbr || !buildingName) { + return null; + } + + return { + id: `search-${placeDetails.place_id ?? buildingAbbr}`, + poi_type: "accessible_entrance", + location_geojson: { + type: "Point", + coordinates: [ + placeDetails.geometry.location.lng, + placeDetails.geometry.location.lat, + ], + }, + metadata: { + name: buildingName, + bld_name: `(${buildingAbbr}) ${buildingName}`, + }, + }; + }; const getMapIcon = useCallback( (poiType: any, metadata: any) => { @@ -132,9 +170,10 @@ export default function Home() { // Handle POI click const handlePOIPress = (poi: any) => { if (isReportMode) return; + const currentId = poi.placeId || poi.id; poiBottomSheetRef.current?.present({ poi }); - if (polygonId[0] == 'C') return; // construction areas - bottomSheetRef.current?.present({ id: polygonId }); + if (currentId && currentId[0] === 'C') return; + bottomSheetRef.current?.present({ id: currentId }); }; const polygons = useMemo( @@ -239,19 +278,59 @@ export default function Home() { // Close search setIsSearchActive(false); setSearchQuery(""); - + + // Resolve missing place_id for recent/manual locations so recenter still works. + let resolvedPlaceId = location.place_id; + if (!resolvedPlaceId) { + const primaryQuery = [location.name, location.address].filter(Boolean).join(" "); + const fallbackQuery = location.name; + + const primaryResults = await searchPlaces(primaryQuery); + resolvedPlaceId = primaryResults[0]?.place_id; + + if (!resolvedPlaceId && fallbackQuery) { + const fallbackResults = await searchPlaces(fallbackQuery); + resolvedPlaceId = fallbackResults[0]?.place_id; + } + } + // Fetch full place details - if (location.place_id) { - const placeDetails = await getPlaceDetails(location.place_id); + if (resolvedPlaceId) { + const placeDetails = await getPlaceDetails(resolvedPlaceId); if (placeDetails) { - // TODO: Get user's current location to calculate distance - // For now, using a placeholder - //const distance = "2.4 Mi"; - - // Open location details bottom sheet with real data - locationBottomSheetRef.current?.present(placeDetails); + const matchingBuilding = findCampusBuildingFeature( + placeDetails.geometry.location.lat, + placeDetails.geometry.location.lng, + placeDetails.name, + placeDetails.formatted_address, + ); + + mapRef.current?.animateToRegion( + { + latitude: placeDetails.geometry.location.lat, + longitude: placeDetails.geometry.location.lng, + latitudeDelta: 0.006, + longitudeDelta: 0.006, + }, + 450, + ); + + const buildingPoi = buildPoiFromCampusBuilding( + placeDetails, + matchingBuilding, + ); + + if (buildingPoi) { + locationBottomSheetRef.current?.dismiss(); + poiBottomSheetRef.current?.present({ poi: buildingPoi }); + } else { + // Open the generic sheet when we cannot map the place to an official building. + locationBottomSheetRef.current?.present(placeDetails); + } } + } else { + console.error("Could not resolve place_id for selected location", location); } }; @@ -310,9 +389,10 @@ export default function Home() { + ref: Ref, ) => { const bottomTabBarHeight = useBottomTabBarHeight(); const bottomSheetRef = useRef(null); - + const [placeData, setPlaceData] = useState(null); const [distance, setDistance] = useState(props.distance); - const [entrances, setEntrances] = useState([]); - // Expose methods to parent via ref useImperativeHandle(ref, () => ({ present: (placeDetails: PlaceDetails, dist?: string) => { setPlaceData(placeDetails); setDistance(dist); - // TODO: Fetch entrances from Cloudflare based on coordinates - setEntrances([]); // Empty for now bottomSheetRef.current?.present(); }, dismiss: () => { @@ -68,71 +46,15 @@ const LocationDetailsBottomSheetComponent = ( })); const renderStars = (rating: number) => { - const stars = []; - const fullStars = Math.floor(rating); - - for (let i = 0; i < 5; i++) { - stars.push( - - ); - } - - return stars; - }; - - const renderAccessIcon = (access: EntranceAccess) => { - const icons = []; + return Array.from({ length: 5 }, (_, i) => { + const StarComponent = i < Math.floor(rating) ? StarFill : StarBorder; - if (access.hasPowerDoor) { - icons.push( - - + return ( + + ); - } - - if (access.hasRamp) { - icons.push( - - - - ); - } - - if (access.hasAccessibleRestroom) { - icons.push( - - - - ); - } - - if (access.hasAccessibleDoor) { - icons.push( - - - - ); - } - - return icons; + }); }; if (!placeData) { @@ -142,7 +64,7 @@ const LocationDetailsBottomSheetComponent = ( bottomInset={bottomTabBarHeight} backgroundStyle={{ borderRadius: 32 }} enableDynamicSizing={false} - snapPoints={["50%", "85%"]} + snapPoints={["50%"]} handleIndicatorStyle={{ backgroundColor: colors.theme.majorgridline, width: 80, @@ -162,152 +84,131 @@ const LocationDetailsBottomSheetComponent = ( bottomInset={bottomTabBarHeight} backgroundStyle={{ borderRadius: 32 }} enableDynamicSizing={false} - snapPoints={["50%", "85%"]} + snapPoints={["50%"]} handleIndicatorStyle={{ backgroundColor: colors.theme.majorgridline, width: 80, }} + enableContentPanningGesture={false} > - - {/* Header: Title and Action Icons */} - - - {placeData.name} - - - - - - + + + - - - - - - {/* Address */} - - - {placeData.formatted_address} - - - {/* Star Rating */} - {placeData.rating && ( - - {renderStars(placeData.rating)} - - {placeData.rating} + {placeData.name} + + + + + + + + - )} - - {/* Reviews */} - {placeData.user_ratings_total && ( - - - Reviews ({placeData.user_ratings_total}) - - - - )} - {/* Hours and Distance */} - - {/* Hours */} - - Hours - - {formatOpeningHours(placeData.opening_hours)} + + + + {placeData.formatted_address} - {/* Distance */} - - Distance - - {distance || "Calculating..."} - + {placeData.rating && ( + + + {renderStars(placeData.rating)} + + + {placeData.rating.toFixed(1)} + + + )} + + {placeData.user_ratings_total && ( + + + + Reviews ({placeData.user_ratings_total}) + + + + + )} + + + + Hours + + {formatOpeningHours(placeData.opening_hours)} + + + + Distance + + {distance || "Calculating..."} + + - - {/* Divider */} - + - {/* Access Section - Only show if we have entrance data */} - {entrances.length > 0 ? ( - - - Access - - - i - - - - - - {entrances.map((entrance) => ( - - - {entrance.name} - - - {renderAccessIcon(entrance.access)} - - - ))} + + Access + + i - ) : ( - - - No accessibility data available for this location yet. + + + + No accessible entrances found. - - - Add accessibility info - - - )} - - {/* Get Directions Button */} -