Add PropertyMap and PriceMarker components with property search functionality
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
63
packages/map-sdk/src/components/PriceMarker.tsx
Normal file
63
packages/map-sdk/src/components/PriceMarker.tsx
Normal 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;
|
||||
236
packages/map-sdk/src/components/PropertyMap.tsx
Normal file
236
packages/map-sdk/src/components/PropertyMap.tsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user