Added interactive property map with markers and floating popups showing price, beds/baths, and View Details button
This commit is contained in:
@@ -6,17 +6,18 @@ import {
|
||||
Platform,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
FlatList,
|
||||
Image,
|
||||
Dimensions,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
|
||||
import MapView from 'react-native-map-clustering';
|
||||
import { Marker, MapType, Region } from 'react-native-maps';
|
||||
import Geolocation from '@react-native-community/geolocation';
|
||||
|
||||
const { height } = Dimensions.get('window');
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
/* ---------------- CONSTANTS ---------------- */
|
||||
|
||||
const DEFAULT_REGION: Region = {
|
||||
latitude: 47.6062,
|
||||
longitude: -122.3321,
|
||||
@@ -25,8 +26,7 @@ const DEFAULT_REGION: Region = {
|
||||
};
|
||||
|
||||
/* ---------------- MOCK DATA ---------------- */
|
||||
|
||||
const generateProperties = (count = 1500) =>
|
||||
const generateProperties = (count = 150) =>
|
||||
Array.from({ length: count }).map((_, i) => ({
|
||||
id: `${i}`,
|
||||
lat: 47.5 + Math.random() * 0.25,
|
||||
@@ -34,12 +34,12 @@ const generateProperties = (count = 1500) =>
|
||||
price: Math.floor(300000 + Math.random() * 4000000),
|
||||
beds: Math.floor(1 + Math.random() * 5),
|
||||
baths: Math.floor(1 + Math.random() * 4),
|
||||
image: `https://picsum.photos/300/200?random=${i}`,
|
||||
}));
|
||||
|
||||
const properties = generateProperties();
|
||||
|
||||
/* ---------------- HELPERS ---------------- */
|
||||
|
||||
const formatPrice = (value: number) => {
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1000) return `${Math.round(value / 1000)}K`;
|
||||
@@ -47,8 +47,7 @@ const formatPrice = (value: number) => {
|
||||
};
|
||||
|
||||
/* ---------------- PRICE MARKER ---------------- */
|
||||
|
||||
const PriceMarker = ({ item, onPress }: any) => {
|
||||
const PriceMarker = ({ item, onPress, selected }: any) => {
|
||||
const [tracks, setTracks] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,11 +60,12 @@ const PriceMarker = ({ item, onPress }: any) => {
|
||||
coordinate={{ latitude: item.lat, longitude: item.lng }}
|
||||
tracksViewChanges={tracks}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation(); // 🔥 prevents map press
|
||||
onPress();
|
||||
e.stopPropagation();
|
||||
onPress(item);
|
||||
}}
|
||||
pinColor={selected ? 'gold' : '#8B0000'}
|
||||
>
|
||||
<View style={styles.pin}>
|
||||
<View style={[styles.pin, selected && styles.selectedPin]}>
|
||||
<Text style={styles.pinText}>{formatPrice(item.price)}</Text>
|
||||
</View>
|
||||
</Marker>
|
||||
@@ -73,18 +73,24 @@ const PriceMarker = ({ item, onPress }: any) => {
|
||||
};
|
||||
|
||||
/* ---------------- MAIN SCREEN ---------------- */
|
||||
|
||||
const MapScreen: React.FC = () => {
|
||||
const mapRef = useRef<MapView>(null);
|
||||
|
||||
const [mapType, setMapType] = useState<MapType>('standard');
|
||||
const [selectedItem, setSelectedItem] = useState<any>(null);
|
||||
const [popupAnim] = useState(new Animated.Value(0));
|
||||
|
||||
/* ---- current location ---- */
|
||||
useEffect(() => {
|
||||
locateUser();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(popupAnim, {
|
||||
toValue: selectedItem ? 1 : 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [selectedItem]);
|
||||
|
||||
const locateUser = async () => {
|
||||
if (Platform.OS === 'android') {
|
||||
const granted = await PermissionsAndroid.request(
|
||||
@@ -93,7 +99,7 @@ const MapScreen: React.FC = () => {
|
||||
if (granted !== PermissionsAndroid.RESULTS.GRANTED) return;
|
||||
}
|
||||
|
||||
Geolocation.getCurrentPosition(pos => {
|
||||
Geolocation.getCurrentPosition((pos) => {
|
||||
const { latitude, longitude } = pos.coords;
|
||||
mapRef.current?.animateToRegion(
|
||||
{
|
||||
@@ -107,6 +113,11 @@ const MapScreen: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const popupTranslateY = popupAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [200, 0],
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MapView
|
||||
@@ -118,15 +129,9 @@ const MapScreen: React.FC = () => {
|
||||
clusterPressMaxZoom={16}
|
||||
spiralEnabled={false}
|
||||
mapType={mapType}
|
||||
onRegionChangeComplete={(r) => {
|
||||
setSelectedItem(null);
|
||||
setMapType(r.latitudeDelta < 0.02 ? 'satellite' : 'standard');
|
||||
}}
|
||||
|
||||
/* ---- CLUSTER ---- */
|
||||
onRegionChangeComplete={(r) => setMapType(r.latitudeDelta < 0.02 ? 'satellite' : 'standard')}
|
||||
renderCluster={(cluster, onPress) => {
|
||||
const { geometry, properties } = cluster;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={`cluster-${properties.cluster_id}`}
|
||||
@@ -137,115 +142,107 @@ const MapScreen: React.FC = () => {
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.clusterBubble}>
|
||||
<Text style={styles.clusterText}>
|
||||
{properties.point_count}
|
||||
</Text>
|
||||
<Text style={styles.clusterText}>{properties.point_count}</Text>
|
||||
</View>
|
||||
</Marker>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{properties.map(item => (
|
||||
{properties.map((item) => (
|
||||
<PriceMarker
|
||||
key={item.id}
|
||||
item={item}
|
||||
onPress={() => setSelectedItem(item)}
|
||||
selected={selectedItem?.id === item.id}
|
||||
onPress={(item) => setSelectedItem(item)}
|
||||
/>
|
||||
))}
|
||||
</MapView>
|
||||
|
||||
{/* -------- BOTTOM POPUP -------- */}
|
||||
{/* -------- HORIZONTAL ZILLOW-STYLE POPUP -------- */}
|
||||
{selectedItem && (
|
||||
<View style={styles.popup}>
|
||||
<TouchableOpacity
|
||||
style={styles.close}
|
||||
onPress={() => setSelectedItem(null)}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.popupContainer,
|
||||
{ transform: [{ translateY: popupTranslateY }] },
|
||||
]}
|
||||
>
|
||||
<Text>✕</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={styles.price}>
|
||||
${formatPrice(selectedItem.price)}
|
||||
<FlatList
|
||||
horizontal
|
||||
data={[selectedItem]}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.card}>
|
||||
<Image source={{ uri: item.image }} style={styles.cardImage} />
|
||||
<View style={styles.cardContent}>
|
||||
<Text style={styles.cardPrice}>${formatPrice(item.price)}</Text>
|
||||
<Text style={styles.cardDetails}>
|
||||
{item.beds} Beds • {item.baths} Baths
|
||||
</Text>
|
||||
|
||||
<Text style={styles.details}>
|
||||
{selectedItem.beds} Beds • {selectedItem.baths} Baths
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity style={styles.btn}>
|
||||
<Text style={styles.btnText}>View Details</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------------- STYLES ---------------- */
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
|
||||
pin: {
|
||||
backgroundColor: '#8B0000',
|
||||
paddingHorizontal: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 18,
|
||||
},
|
||||
pinText: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
fontSize: 12,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: '#fff',
|
||||
},
|
||||
selectedPin: { backgroundColor: 'gold', borderColor: '#333' },
|
||||
pinText: { color: '#fff', fontWeight: '600', fontSize: 12 },
|
||||
|
||||
clusterBubble: {
|
||||
backgroundColor: '#8B0000',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 24,
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
},
|
||||
clusterText: {
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 13,
|
||||
},
|
||||
clusterText: { color: '#fff', fontWeight: 'bold', fontSize: 14 },
|
||||
|
||||
popup: {
|
||||
popupContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: 16,
|
||||
right: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
width: width * 0.8,
|
||||
marginHorizontal: 10,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
elevation: 10,
|
||||
overflow: 'hidden',
|
||||
elevation: 5,
|
||||
},
|
||||
close: {
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
top: 12,
|
||||
cardImage: {
|
||||
width: '100%',
|
||||
height: 140,
|
||||
},
|
||||
price: {
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
details: {
|
||||
marginVertical: 6,
|
||||
color: '#666',
|
||||
},
|
||||
btn: {
|
||||
marginTop: 12,
|
||||
cardContent: { padding: 12 },
|
||||
cardPrice: { fontSize: 18, fontWeight: 'bold' },
|
||||
cardDetails: { marginVertical: 4, color: '#666' },
|
||||
cardBtn: {
|
||||
marginTop: 8,
|
||||
backgroundColor: '#8B0000',
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnText: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
cardBtnText: { color: '#fff', fontWeight: '600' },
|
||||
});
|
||||
|
||||
export default MapScreen;
|
||||
|
||||
Reference in New Issue
Block a user