Add PropertyMap and PriceMarker components with property search functionality

This commit is contained in:
2026-01-31 23:22:12 +05:30
parent 5b04cc9a58
commit b9cae949d4
6 changed files with 380 additions and 20 deletions

View File

@@ -1,14 +1,18 @@
import React, { useRef } from 'react';
import React, { useRef, useState } from 'react';
import { SafeAreaView, StyleSheet, Button, View } from 'react-native';
import { MapView, Marker } from '@lynkedup/map-sdk';
import { MapView, Marker, PropertyMap } from '@lynkedup/map-sdk';
import type { MapHandle } from '@lynkedup/map-sdk';
export default function App() {
const mapRef = useRef<MapHandle | null>(null);
const [showPropertyMap, setShowPropertyMap] = useState(false);
return (
<SafeAreaView style={styles.container}>
<View style={styles.controls}>
<Button title="Toggle SDK Example" onPress={() => setShowPropertyMap((s) => !s)} />
{!showPropertyMap && (
<View style={{ flexDirection: 'row' }}>
<Button
title="Fly to SF"
onPress={() => mapRef.current?.flyTo({ latitude: 37.78825, longitude: -122.4324 })}
@@ -20,6 +24,12 @@ export default function App() {
}
/>
</View>
)}
</View>
{showPropertyMap ? (
<PropertyMap apiUrl="http://64.227.108.180:5000/property-search" />
) : (
<MapView
ref={mapRef}
style={styles.map}
@@ -27,6 +37,7 @@ export default function App() {
>
<Marker coordinate={{ latitude: 37.78825, longitude: -122.4324 }} />
</MapView>
)}
</SafeAreaView>
);
}

View File

@@ -50,5 +50,47 @@ Available methods:
---
## PropertyMap (high-level SDK component) ✅
`PropertyMap` is a ready-made map screen for property searches — it includes a search bar, clustering support, polygon rendering and a bottom property card. It's ideal to embed in apps that want a turn-key property map.
Install the extra peer deps in your app:
```bash
npm install react-native-map-clustering @react-native-community/geolocation
# and make sure react-native-maps is installed and configured
```
Basic usage:
```tsx
import React from 'react';
import { PropertyMap } from '@lynkedup/map-sdk';
export default function PropertiesScreen() {
return (
<PropertyMap
apiUrl="https://your-api/property-search"
initialRegion={{ latitude: 25.48, longitude: 81.85, latitudeDelta: 0.02, longitudeDelta: 0.02 }}
showSearch
radius={500}
onSelectProperty={(item) => console.log('selected', item)}
/>
);
}
```
Props summary:
- `apiUrl?: string` — POST endpoint used for property searches (defaults to the demo host).
- `initialRegion?: Region` — starting region.
- `radius?: number` — search radius in meters (default: 500).
- `showSearch?: boolean` — render search bar (default: true).
- `onSelectProperty?: (item) => void` — callback for property selection.
---
---
## Notes
- This package delegates to `react-native-maps` for platform implementations. Follow `react-native-maps` docs for iOS/Android setup (Google Maps API key, pods, manifest permissions).

View File

@@ -14,7 +14,9 @@
"peerDependencies": {
"react": ">=17",
"react-native": ">=0.70",
"react-native-maps": "^1.3.2"
"react-native-maps": "^1.3.2",
"react-native-map-clustering": "^3.4.0 || ^4.0.0",
"@react-native-community/geolocation": "^2.0.2"
},
"devDependencies": {
"typescript": "^5.0.0",

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Marker } from 'react-native-maps';
export type PropertyItem = {
id: string | number;
location?: { lat: number; lng: number };
price?: number;
price_display?: string;
};
export const formatPrice = (value?: number | string) => {
if (!value) return '';
const num = typeof value === 'string' ? Number(value) : value;
if (!num) return String(value);
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1000) return `${Math.round(num / 1000)}K`;
return num.toString();
};
const PriceMarker: React.FC<{
item: PropertyItem;
selected?: boolean;
onPress?: (item: PropertyItem) => void;
}> = ({ item, selected, onPress }) => {
if (!item?.location) return null;
return (
<Marker
coordinate={{ latitude: item.location.lat, longitude: item.location.lng }}
onPress={(e) => {
e.stopPropagation();
onPress && onPress(item);
}}
>
<View style={[styles.pin, selected && styles.selectedPin]}>
<Text style={styles.pinText}>{item.price_display ?? formatPrice(item.price)}</Text>
</View>
</Marker>
);
};
const styles = StyleSheet.create({
pin: {
backgroundColor: '#8B0000',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
borderWidth: 1,
borderColor: '#fff',
},
selectedPin: {
backgroundColor: '#F4C430',
borderColor: '#333',
},
pinText: {
color: '#fff',
fontWeight: '600',
fontSize: 12,
},
});
export default PriceMarker;

View File

@@ -0,0 +1,236 @@
import React, { useEffect, useRef, useState } from 'react';
import {
View,
StyleSheet,
Platform,
Text,
FlatList,
Image,
Dimensions,
Animated,
ActivityIndicator,
TextInput,
TouchableOpacity,
} from 'react-native';
import ClusteredMapView from 'react-native-map-clustering';
import { Polygon } from 'react-native-maps';
import Geolocation from '@react-native-community/geolocation';
import PriceMarker, { formatPrice, PropertyItem } from './PriceMarker';
const { width } = Dimensions.get('window');
export type PropertyMapProps = {
apiUrl?: string;
initialRegion?: any;
radius?: number;
showSearch?: boolean;
onSelectProperty?: (item: PropertyItem) => void;
};
export const DEFAULT_REGION = {
latitude: 25.48131,
longitude: 81.854249,
latitudeDelta: 0.02,
longitudeDelta: 0.02,
};
export const getPolygonCoords = (polygon?: number[][]) => {
if (!polygon) return [];
return polygon.map(([lat, lng]) => ({ latitude: lat, longitude: lng }));
};
const PropertyMap: React.FC<PropertyMapProps> = ({
apiUrl = 'http://64.227.108.180:5000/property-search',
initialRegion = DEFAULT_REGION,
radius = 500,
showSearch = true,
onSelectProperty,
}) => {
const mapRef = useRef<any>(null);
const [region, setRegion] = useState<any>(initialRegion);
const [properties, setProperties] = useState<PropertyItem[]>([]);
const [selectedItem, setSelectedItem] = useState<PropertyItem | null>(null);
const [loading, setLoading] = useState(false);
const [searchText, setSearchText] = useState('');
const popupAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
requestLocation();
}, []);
useEffect(() => {
Animated.timing(popupAnim, {
toValue: selectedItem ? 1 : 0,
duration: 250,
useNativeDriver: true,
}).start();
}, [selectedItem]);
const requestLocation = async () => {
try {
Geolocation.getCurrentPosition((pos) => {
const { latitude, longitude } = pos.coords;
const newRegion = { latitude, longitude, latitudeDelta: 0.02, longitudeDelta: 0.02 };
setRegion(newRegion);
mapRef.current?.animateToRegion?.(newRegion, 1000);
});
} catch (err) {
// ignore
}
};
const fetchProperties = async (locationName: string) => {
if (!locationName) return;
try {
setLoading(true);
setSelectedItem(null);
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ location: locationName, radius }),
});
const json = await response.json();
if (json?.success && json?.location?.coordinates?.lat && json?.location?.coordinates?.lng) {
const newRegion = {
latitude: json.location.coordinates.lat,
longitude: json.location.coordinates.lng,
latitudeDelta: 0.02,
longitudeDelta: 0.02,
};
setProperties(json.properties || []);
setRegion(newRegion);
mapRef.current?.animateToRegion?.(newRegion, 1000);
}
} catch (error) {
console.warn('PropertyMap API Error:', error);
} finally {
setLoading(false);
}
};
const onSelect = (item: PropertyItem) => {
setSelectedItem(item);
onSelectProperty && onSelectProperty(item);
if ((item as any).polygon?.length) {
mapRef.current?.fitToCoordinates?.(getPolygonCoords((item as any).polygon), {
edgePadding: { top: 80, right: 80, bottom: 300, left: 80 },
animated: true,
});
}
};
const popupTranslateY = popupAnim.interpolate({ inputRange: [0, 1], outputRange: [200, 0] });
return (
<View style={styles.container}>
{showSearch && (
<View style={styles.searchContainer}>
<TextInput
value={searchText}
onChangeText={setSearchText}
placeholder="Search area, locality..."
style={styles.searchInput}
returnKeyType="search"
onSubmitEditing={() => fetchProperties(searchText)}
/>
<TouchableOpacity style={styles.searchButton} onPress={() => fetchProperties(searchText)}>
<Text style={styles.searchText}>Search</Text>
</TouchableOpacity>
</View>
)}
<ClusteredMapView
ref={mapRef}
style={StyleSheet.absoluteFillObject}
region={region}
onRegionChangeComplete={(r: any) => {
if (!r?.longitudeDelta) return;
setRegion(r);
}}
showsUserLocation
animationEnabled
clusterPressMaxZoom={16}
spiralEnabled={false}
>
{properties.map((item) =>
(item as any).polygon ? (
<Polygon
key={`poly-${item.id}`}
coordinates={getPolygonCoords((item as any).polygon)}
strokeColor={selectedItem?.id === item.id ? '#F4C430' : '#8B0000'}
fillColor="rgba(139, 0, 0, 0.15)"
strokeWidth={2}
/>
) : null
)}
{properties.map((item) => (
<PriceMarker key={String(item.id)} item={item} selected={selectedItem?.id === item.id} onPress={onSelect} />
))}
</ClusteredMapView>
{loading && (
<View style={styles.loader}>
<ActivityIndicator size="large" />
</View>
)}
{selectedItem && (
<Animated.View style={[styles.popupContainer, { transform: [{ translateY: popupTranslateY }] }]}>
<FlatList
horizontal
data={[selectedItem]}
keyExtractor={(item) => String(item.id)}
showsHorizontalScrollIndicator={false}
renderItem={({ item }) => (
<View style={styles.card}>
<Image source={{ uri: 'https://picsum.photos/400/200' }} style={styles.cardImage} />
<View style={styles.cardContent}>
<Text style={styles.cardPrice}>{item.price_display ?? formatPrice(item.price)}</Text>
<Text style={styles.cardDetails}>{(item as any).bedrooms} Beds {(item as any).bathrooms} Baths</Text>
<Text style={styles.cardAddress} numberOfLines={2}>
{(item as any).address}
</Text>
</View>
</View>
)}
/>
</Animated.View>
)}
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
searchContainer: {
position: 'absolute',
top: 40,
left: 16,
right: 16,
zIndex: 10,
flexDirection: 'row',
backgroundColor: '#fff',
borderRadius: 8,
elevation: 6,
},
searchInput: { flex: 1, padding: 12 },
searchButton: { paddingHorizontal: 16, justifyContent: 'center', backgroundColor: '#8B0000', borderTopRightRadius: 8, borderBottomRightRadius: 8 },
searchText: { color: '#fff', fontWeight: '600' },
popupContainer: { position: 'absolute', bottom: 20 },
card: { backgroundColor: '#fff', width: width * 0.8, marginHorizontal: 10, borderRadius: 16, overflow: 'hidden', elevation: 6 },
cardImage: { width: '100%', height: 140 },
cardContent: { padding: 12 },
cardPrice: { fontSize: 18, fontWeight: 'bold' },
cardDetails: { marginVertical: 4, color: '#555' },
cardAddress: { fontSize: 13, color: '#777' },
loader: { position: 'absolute', top: '50%', alignSelf: 'center' },
});
export default PropertyMap;

View File

@@ -1,3 +1,9 @@
export { default as MapView } from './MapView';
export type { MapHandle } from './MapView';
export { Marker, Polyline, PROVIDER_GOOGLE } from 'react-native-maps';
// High-level components
export { default as PropertyMap } from './components/PropertyMap';
export { default as PriceMarker } from './components/PriceMarker';
export { formatPrice } from './components/PriceMarker';
export type { PropertyItem } from './components/PriceMarker';