Skip to content

Commit 012b8f8

Browse files
committed
refactor(mobile/search): pick a place to recenter the map
Typed queries now hit places.search and render neighbourhoods and landmarks — no more mixing with local mosque results. Tapping a result navigates to the map with lat/lng params; the map animates there, pins a marker, and refetches nearby mosques from the picked centre via the existing mosques.nearby hook. Recenter button clears the picked-place override. Recent and popular sections at the bottom still show mosques when the search input is empty.
1 parent 97d073b commit 012b8f8

3 files changed

Lines changed: 121 additions & 58 deletions

File tree

apps/mobile/features/map/components/map-screen.tsx

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type BottomSheet from "@gorhom/bottom-sheet";
2+
import { router, useLocalSearchParams } from "expo-router";
23
import { StatusBar } from "expo-status-bar";
3-
import { useCallback, useMemo, useRef, useState } from "react";
4+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
45
import { Pressable, StyleSheet, View } from "react-native";
5-
import MapView, { PROVIDER_GOOGLE } from "react-native-maps";
6+
import MapView, { Marker, PROVIDER_GOOGLE } from "react-native-maps";
7+
import { Icon } from "@/components/ui/icon";
68
import {
79
applyFilters,
810
countActive,
@@ -18,16 +20,44 @@ import { MapMarkers } from "./map-markers";
1820
import { MapMosqueSheet } from "./map-mosque-sheet";
1921
import { MapTopOverlay } from "./map-top-overlay";
2022

23+
type PickedPlace = { lat: number; lng: number; name?: string };
24+
25+
function parsePickedPlace(
26+
params: Record<string, string | string[] | undefined>,
27+
): PickedPlace | null {
28+
const lat = Array.isArray(params.lat) ? params.lat[0] : params.lat;
29+
const lng = Array.isArray(params.lng) ? params.lng[0] : params.lng;
30+
const name = Array.isArray(params.placeName)
31+
? params.placeName[0]
32+
: params.placeName;
33+
if (!lat || !lng) return null;
34+
const latNum = Number(lat);
35+
const lngNum = Number(lng);
36+
if (!Number.isFinite(latNum) || !Number.isFinite(lngNum)) return null;
37+
return { lat: latNum, lng: lngNum, name };
38+
}
39+
2140
export function MapScreen() {
2241
const filters = useMosqueFilters();
2342
const activeFilters = countActive(filters);
2443
const userPos = useUserLocation();
2544

26-
// Use the server-side distance filter when the user has both a location and a radius.
45+
const searchParams = useLocalSearchParams();
46+
const pickedPlace = useMemo(
47+
() => parsePickedPlace(searchParams),
48+
[searchParams],
49+
);
50+
51+
// The centre point used for nearby-mosque fetches. Picked place overrides
52+
// the user's real location so "explore Gulshan" shows mosques near Gulshan,
53+
// not near the user's actual GPS position.
54+
const center = pickedPlace ?? userPos;
55+
56+
// Use the server-side distance filter when we have both a centre and a radius.
2757
// Otherwise fall back to the full list (we still apply toggles client-side).
2858
const nearbyParams =
29-
userPos && filters.radiusKm != null
30-
? { lat: userPos.lat, lng: userPos.lng, radiusKm: filters.radiusKm }
59+
center && filters.radiusKm != null
60+
? { lat: center.lat, lng: center.lng, radiusKm: filters.radiusKm }
3161
: null;
3262

3363
const list = useMosquesList({ pageSize: 50 });
@@ -46,12 +76,26 @@ export function MapScreen() {
4676
const sheetRef = useRef<BottomSheet>(null);
4777
const mapRef = useRef<MapView>(null);
4878

49-
const coords = userPos ?? {
79+
const coords = center ?? {
5080
lat: DHAKA_REGION.latitude,
5181
lng: DHAKA_REGION.longitude,
5282
};
5383
const { data: prayer } = usePrayerTimes(coords);
5484

85+
// Animate to the picked place when it changes.
86+
useEffect(() => {
87+
if (!pickedPlace) return;
88+
mapRef.current?.animateToRegion(
89+
{
90+
latitude: pickedPlace.lat,
91+
longitude: pickedPlace.lng,
92+
latitudeDelta: 0.04,
93+
longitudeDelta: 0.04,
94+
},
95+
600,
96+
);
97+
}, [pickedPlace]);
98+
5599
const handleMarkerPress = useCallback((m: MosqueListItem) => {
56100
setSelected(m);
57101
sheetRef.current?.snapToIndex(0);
@@ -76,6 +120,15 @@ export function MapScreen() {
76120
lat: DHAKA_REGION.latitude,
77121
lng: DHAKA_REGION.longitude,
78122
};
123+
// Pressing recenter drops any picked-place override and returns to the
124+
// user's real location.
125+
if (pickedPlace) {
126+
router.setParams({
127+
lat: undefined,
128+
lng: undefined,
129+
placeName: undefined,
130+
});
131+
}
79132
mapRef.current?.animateToRegion(
80133
{
81134
latitude: target.lat,
@@ -85,7 +138,7 @@ export function MapScreen() {
85138
},
86139
500,
87140
);
88-
}, [userPos]);
141+
}, [userPos, pickedPlace]);
89142

90143
return (
91144
<View className="flex-1 bg-cream">
@@ -103,6 +156,20 @@ export function MapScreen() {
103156
onPoiClick={handleCloseSheet}
104157
>
105158
<MapMarkers mosques={mosques} onSelect={handleMarkerPress} />
159+
{pickedPlace ? (
160+
<Marker
161+
coordinate={{
162+
latitude: pickedPlace.lat,
163+
longitude: pickedPlace.lng,
164+
}}
165+
title={pickedPlace.name ?? "Picked location"}
166+
pinColor="#2e5d45"
167+
>
168+
<View className="h-7 w-7 items-center justify-center rounded-pill border-2 border-white bg-green shadow-sm">
169+
<Icon name="pin" size={14} color="#ffffff" />
170+
</View>
171+
</Marker>
172+
) : null}
106173
</MapView>
107174

108175
{menuOpen ? (
@@ -119,7 +186,7 @@ export function MapScreen() {
119186
timings={prayer?.timings ?? null}
120187
onRetry={() => refetch()}
121188
onRecenter={handleRecenter}
122-
canRecenter={Boolean(userPos)}
189+
canRecenter={Boolean(userPos) || Boolean(pickedPlace)}
123190
activeFilters={activeFilters}
124191
menuOpen={menuOpen}
125192
onOpenMenu={() => setMenuOpen(true)}

apps/mobile/features/mosques/components/search-modal-screen.tsx

Lines changed: 24 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,25 @@
11
import { useQuery } from "@tanstack/react-query";
22
import { StatusBar } from "expo-status-bar";
3-
import { useEffect, useMemo, useState } from "react";
3+
import { useEffect, useState } from "react";
44
import { ScrollView, View } from "react-native";
55
import { Icon } from "@/components/ui/icon";
66
import { Text } from "@/components/ui/text";
77
import { api } from "@/lib/api";
88
import { useUserLocation } from "@/lib/use-user-location";
99
import { useMosquesList } from "../hooks/use-mosques";
10-
import { applySearch } from "../lib/apply-filters";
1110
import { SearchInputBar } from "./search-input-bar";
1211
import { SearchPlacesRow } from "./search-places-row";
1312
import { SearchPopularSection } from "./search-popular-section";
1413
import { SearchRecentSection } from "./search-recent-section";
15-
import { SearchResultRow } from "./search-result-row";
16-
import { SearchResultsSkeleton } from "./search-results-skeleton";
1714

1815
export function SearchModalScreen() {
1916
const [query, setQuery] = useState("");
2017
const [debouncedQuery, setDebouncedQuery] = useState("");
21-
const { data, isLoading } = useMosquesList({ pageSize: 50 });
18+
const { data } = useMosquesList({ pageSize: 50 });
2219
const mosques = data?.data ?? [];
2320
const userPos = useUserLocation();
2421

25-
const results = useMemo(() => applySearch(mosques, query), [mosques, query]);
26-
27-
// Debounce the query before hitting Google Places so slider-quick typing
22+
// Debounce before hitting Google Places so slider-quick typing
2823
// doesn't burn quota. 400ms feels responsive without being chatty.
2924
useEffect(() => {
3025
const trimmed = query.trim();
@@ -46,7 +41,7 @@ export function SearchModalScreen() {
4641
userPos?.lng,
4742
] as const,
4843
queryFn: () =>
49-
api.places.searchMosques({
44+
api.places.search({
5045
query: debouncedQuery,
5146
lat: userPos?.lat,
5247
lng: userPos?.lng,
@@ -56,6 +51,8 @@ export function SearchModalScreen() {
5651
});
5752

5853
const placeResults = places.data?.data ?? [];
54+
const searching = placesEnabled;
55+
const isPlacesLoading = places.isFetching && placeResults.length === 0;
5956

6057
return (
6158
<View className="flex-1 bg-cream">
@@ -71,37 +68,35 @@ export function SearchModalScreen() {
7168
keyboardShouldPersistTaps="handled"
7269
showsVerticalScrollIndicator={false}
7370
>
74-
{isLoading ? (
75-
<SearchResultsSkeleton />
76-
) : query ? (
71+
{searching ? (
7772
<View className="gap-s-6">
78-
{results.length === 0 && placeResults.length === 0 ? (
73+
<Text variant="eyebrow" tone="muted">
74+
Places
75+
</Text>
76+
77+
{isPlacesLoading ? (
78+
<View className="items-center rounded-md bg-white py-s-6">
79+
<Text variant="caption" tone="muted">
80+
Searching…
81+
</Text>
82+
</View>
83+
) : placeResults.length === 0 ? (
7984
<View className="items-center gap-s-2 rounded-md bg-white px-s-5 py-s-8">
8085
<Icon name="search" size={28} color="#6b7a70" />
8186
<Text variant="label" tone="muted">
82-
No matches for "{query.trim()}"
87+
No places found for "{query.trim()}"
88+
</Text>
89+
<Text variant="caption" tone="muted" className="text-center">
90+
Try a neighbourhood, landmark, or address.
8391
</Text>
8492
</View>
85-
) : null}
86-
87-
{results.length > 0 ? (
88-
<View className="gap-s-3">
89-
{results.map((m) => (
90-
<SearchResultRow key={m.id} mosque={m} />
91-
))}
92-
</View>
93-
) : null}
94-
95-
{placesEnabled && placeResults.length > 0 ? (
93+
) : (
9694
<View className="gap-s-3">
97-
<Text variant="eyebrow" tone="muted">
98-
More nearby — via Google
99-
</Text>
10095
{placeResults.map((p) => (
10196
<SearchPlacesRow key={p.placeId} place={p} />
10297
))}
10398
</View>
104-
) : null}
99+
)}
105100
</View>
106101
) : (
107102
<View className="gap-s-7">

apps/mobile/features/mosques/components/search-places-row.tsx

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Alert, Pressable, View } from "react-native";
1+
import { router } from "expo-router";
2+
import { Pressable, View } from "react-native";
23
import { Icon } from "@/components/ui/icon";
34
import { Text } from "@/components/ui/text";
45

@@ -7,6 +8,8 @@ type Props = {
78
placeId: string;
89
name: string;
910
address: string | null;
11+
lat: number;
12+
lng: number;
1013
distanceKm?: number;
1114
};
1215
};
@@ -17,34 +20,32 @@ function formatDistance(km: number) {
1720
}
1821

1922
export function SearchPlacesRow({ place }: Props) {
20-
const onSuggest = () => {
21-
Alert.alert(
22-
"Suggest this mosque",
23-
`${place.name} isn't in Qibla yet. We'll review and add it soon — thanks for flagging it.`,
24-
[{ text: "OK", style: "default" }],
25-
);
23+
const onPick = () => {
24+
// Recenter the map on this place; nearby mosques will refetch from the
25+
// new center via map-screen's search-params handling.
26+
router.replace({
27+
pathname: "/(tabs)/map",
28+
params: {
29+
lat: String(place.lat),
30+
lng: String(place.lng),
31+
placeName: place.name,
32+
},
33+
});
2634
};
2735

2836
return (
2937
<Pressable
30-
onPress={onSuggest}
31-
className="flex-row items-center gap-s-3 rounded-md border border-dashed border-line bg-white p-s-4"
38+
onPress={onPick}
39+
className="flex-row items-center gap-s-3 rounded-md bg-white p-s-4"
3240
style={({ pressed }) => ({ opacity: pressed ? 0.8 : 1 })}
3341
>
34-
<View className="h-10 w-10 items-center justify-center rounded-sm bg-[#f4ead0]">
35-
<Icon name="pin" size={16} color="#8a6a1f" />
42+
<View className="h-10 w-10 items-center justify-center rounded-sm bg-green-tint">
43+
<Icon name="pin" size={16} color="#2e5d45" />
3644
</View>
3745
<View className="flex-1">
38-
<View className="flex-row items-center gap-s-2">
39-
<Text variant="label" numberOfLines={1} className="flex-1">
40-
{place.name}
41-
</Text>
42-
<View className="rounded-sm bg-[#f4ead0] px-s-2 py-s-1">
43-
<Text variant="caption" tone="muted" className="text-[10px]">
44-
GOOGLE
45-
</Text>
46-
</View>
47-
</View>
46+
<Text variant="label" numberOfLines={1}>
47+
{place.name}
48+
</Text>
4849
{place.address ? (
4950
<Text
5051
variant="caption"

0 commit comments

Comments
 (0)