← back to Beverlyhillsvideos App
app/(tabs)/map.tsx
493 lines
import React, { useState, useCallback, useMemo, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
Share,
Linking,
Platform,
Dimensions,
} from 'react-native';
import MapView, { Marker, Callout, Region, PROVIDER_DEFAULT } from 'react-native-maps';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Colors } from '@/constants/Colors';
import { useFavorites } from '@/hooks/useFavorites';
import FilmModal from '@/components/FilmModal';
import FavoritesModal from '@/components/FavoritesModal';
import { MapLocation, LocationType } from '@/types';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const locationsData = require('@/assets/map-locations.json') as { locations: MapLocation[] };
// eslint-disable-next-line @typescript-eslint/no-var-requires
const filmsData = require('@/assets/films.json') as { name: string; slug: string }[];
const BEVERLY_HILLS_REGION: Region = {
latitude: 34.0696,
longitude: -118.4004,
latitudeDelta: 0.035,
longitudeDelta: 0.035,
};
const FILTER_TYPES: { key: LocationType | 'all'; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'restaurant', label: 'Dining' },
{ key: 'store', label: 'Shopping' },
{ key: 'doctor', label: 'Wellness' },
{ key: 'lawyer', label: 'Legal' },
];
function markerColor(type: LocationType): string {
switch (type) {
case 'restaurant': return Colors.markerRestaurant;
case 'store': return Colors.markerStore;
case 'doctor': return Colors.markerDoctor;
case 'lawyer': return Colors.markerLawyer;
default: return Colors.ink;
}
}
function nameToSlug(name: string): string {
return name
.toLowerCase()
.replace(/['']/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function findFilmSlug(locationName: string): string | null {
const normalized = nameToSlug(locationName);
const match = filmsData.find(
(f) =>
f.slug === normalized ||
nameToSlug(f.name) === normalized ||
f.slug === normalized.replace(/-beverly-hills$/, '') ||
normalized.startsWith(f.slug)
);
return match?.slug ?? null;
}
interface CalloutContentProps {
location: MapLocation;
isFavorite: boolean;
onToggleFavorite: (name: string) => void;
onCall: () => void;
onMenu: () => void;
onFilm: () => void;
hasFilm: boolean;
}
function CalloutContent({
location,
isFavorite,
onToggleFavorite,
onCall,
onMenu,
onFilm,
hasFilm,
}: CalloutContentProps) {
return (
<View style={calloutStyles.container}>
<View style={calloutStyles.header}>
<View style={calloutStyles.headerText}>
<Text style={calloutStyles.name} numberOfLines={2}>
{location.name}
</Text>
<Text style={calloutStyles.category}>{location.category}</Text>
</View>
<TouchableOpacity
onPress={() => onToggleFavorite(location.name)}
hitSlop={6}
style={calloutStyles.heartBtn}
>
<Text style={[calloutStyles.heart, isFavorite && calloutStyles.heartActive]}>
{isFavorite ? 'hearts' : 'heart'}
</Text>
</TouchableOpacity>
</View>
<Text style={calloutStyles.address} numberOfLines={2}>
{location.address}
</Text>
<View style={calloutStyles.actions}>
{location.phone ? (
<TouchableOpacity style={calloutStyles.btn} onPress={onCall}>
<Text style={calloutStyles.btnText}>Call</Text>
</TouchableOpacity>
) : null}
{location.menu_url ? (
<TouchableOpacity style={[calloutStyles.btn, calloutStyles.btnOutline]} onPress={onMenu}>
<Text style={calloutStyles.btnOutlineText}>Menu</Text>
</TouchableOpacity>
) : null}
{hasFilm ? (
<TouchableOpacity style={[calloutStyles.btn, calloutStyles.btnFilm]} onPress={onFilm}>
<Text style={calloutStyles.btnText}>Film</Text>
</TouchableOpacity>
) : null}
</View>
</View>
);
}
const calloutStyles = StyleSheet.create({
container: {
width: 230,
padding: 12,
backgroundColor: '#fff',
borderRadius: 10,
shadowColor: '#000',
shadowOpacity: 0.15,
shadowRadius: 8,
shadowOffset: { width: 0, height: 3 },
elevation: 4,
},
header: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 4,
},
headerText: {
flex: 1,
marginRight: 8,
},
name: {
fontSize: 14,
fontWeight: '700',
color: Colors.ink,
lineHeight: 18,
},
category: {
fontSize: 11,
color: Colors.gold,
fontWeight: '600',
marginTop: 2,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
heartBtn: {
padding: 2,
},
heart: {
fontSize: 18,
color: Colors.border,
},
heartActive: {
color: Colors.gold,
},
address: {
fontSize: 11,
color: Colors.textSecondary,
lineHeight: 15,
marginBottom: 10,
},
actions: {
flexDirection: 'row',
gap: 6,
flexWrap: 'wrap',
},
btn: {
backgroundColor: Colors.green,
paddingVertical: 5,
paddingHorizontal: 12,
borderRadius: 5,
},
btnOutline: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: Colors.green,
},
btnFilm: {
backgroundColor: Colors.gold,
},
btnText: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
},
btnOutlineText: {
color: Colors.green,
fontSize: 12,
fontWeight: '600',
},
});
export default function MapScreen() {
const mapRef = useRef<MapView>(null);
const [activeFilter, setActiveFilter] = useState<LocationType | 'all'>('all');
const [filmModal, setFilmModal] = useState<{ url: string; title: string } | null>(null);
const [showFavorites, setShowFavorites] = useState(false);
const { favorites, isFavorite, toggle } = useFavorites();
const locations = locationsData.locations;
const filtered = useMemo(() => {
if (activeFilter === 'all') return locations;
return locations.filter((loc) => loc.type === activeFilter);
}, [locations, activeFilter]);
const handleCall = useCallback((phone: string) => {
Linking.openURL(`tel:${phone}`).catch(() => {});
}, []);
const handleMenu = useCallback((url: string) => {
Linking.openURL(url).catch(() => {});
}, []);
const handleFilm = useCallback((location: MapLocation) => {
const slug = location.slug ?? findFilmSlug(location.name);
if (slug) {
setFilmModal({
url: `https://beverlyhillsvideos.com/restaurants/${slug}.html`,
title: location.name,
});
}
}, []);
const handleShare = useCallback(async () => {
try {
await Share.share({
title: 'Beverly Hills Videos — Map',
message: 'Explore Beverly Hills dining, shopping & more — https://beverlyhillsvideos.com',
url: 'https://beverlyhillsvideos.com',
});
} catch {
// user dismissed
}
}, []);
const handleRecenter = useCallback(() => {
mapRef.current?.animateToRegion(BEVERLY_HILLS_REGION, 400);
}, []);
const favCount = favorites.size;
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Beverly Hills</Text>
<View style={styles.headerActions}>
<TouchableOpacity onPress={handleShare} style={styles.iconBtn} hitSlop={6}>
<Text style={styles.iconText}>share</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setShowFavorites(true)}
style={styles.iconBtn}
hitSlop={6}
>
<Text style={styles.iconText}>favorite</Text>
{favCount > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{favCount > 9 ? '9+' : favCount}</Text>
</View>
)}
</TouchableOpacity>
</View>
</View>
{/* Filter chips */}
<View style={styles.filterBar}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterScroll}
>
{FILTER_TYPES.map((f) => (
<TouchableOpacity
key={f.key}
style={[
styles.chip,
activeFilter === f.key && styles.chipActive,
]}
onPress={() => setActiveFilter(f.key)}
activeOpacity={0.75}
>
<Text
style={[
styles.chipText,
activeFilter === f.key && styles.chipTextActive,
]}
>
{f.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
{/* Map */}
<MapView
ref={mapRef}
style={styles.map}
provider={PROVIDER_DEFAULT}
initialRegion={BEVERLY_HILLS_REGION}
showsUserLocation
showsMyLocationButton={false}
showsCompass
showsScale
>
{filtered.map((location, idx) => {
const slug = location.slug ?? findFilmSlug(location.name);
const hasFilm = Boolean(slug);
const color = markerColor(location.type);
return (
<Marker
key={`${location.name}-${idx}`}
coordinate={{ latitude: location.lat, longitude: location.lng }}
pinColor={color}
title={location.name}
>
<Callout tooltip onPress={() => {}}>
<CalloutContent
location={location}
isFavorite={isFavorite(location.name)}
onToggleFavorite={toggle}
onCall={() => location.phone && handleCall(location.phone)}
onMenu={() => location.menu_url && handleMenu(location.menu_url)}
onFilm={() => handleFilm(location)}
hasFilm={hasFilm}
/>
</Callout>
</Marker>
);
})}
</MapView>
{/* Recenter button */}
<TouchableOpacity style={styles.recenterBtn} onPress={handleRecenter} activeOpacity={0.85}>
<Text style={styles.recenterText}>center_focus_strong</Text>
</TouchableOpacity>
{/* Film modal */}
{filmModal && (
<FilmModal
visible={Boolean(filmModal)}
url={filmModal.url}
title={filmModal.title}
onClose={() => setFilmModal(null)}
/>
)}
{/* Favorites modal */}
<FavoritesModal
visible={showFavorites}
onClose={() => setShowFavorites(false)}
locations={locations}
favorites={favorites}
onToggleFavorite={toggle}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.cream,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 10,
backgroundColor: Colors.cream,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: Colors.border,
},
headerTitle: {
flex: 1,
fontSize: 20,
fontWeight: '700',
color: Colors.ink,
letterSpacing: 0.2,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 14,
},
iconBtn: {
position: 'relative',
padding: 4,
},
iconText: {
fontSize: 22,
color: Colors.ink,
},
badge: {
position: 'absolute',
top: -2,
right: -2,
backgroundColor: Colors.gold,
borderRadius: 8,
minWidth: 16,
height: 16,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 3,
},
badgeText: {
color: '#fff',
fontSize: 9,
fontWeight: '700',
},
filterBar: {
backgroundColor: Colors.cream,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: Colors.border,
},
filterScroll: {
paddingHorizontal: 14,
paddingVertical: 8,
gap: 8,
},
chip: {
paddingVertical: 6,
paddingHorizontal: 16,
borderRadius: 20,
backgroundColor: Colors.surface,
borderWidth: 1,
borderColor: Colors.border,
},
chipActive: {
backgroundColor: Colors.green,
borderColor: Colors.green,
},
chipText: {
fontSize: 13,
fontWeight: '500',
color: Colors.textSecondary,
},
chipTextActive: {
color: '#fff',
fontWeight: '600',
},
map: {
flex: 1,
},
recenterBtn: {
position: 'absolute',
bottom: 24,
right: 18,
backgroundColor: Colors.cream,
borderRadius: 26,
width: 52,
height: 52,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOpacity: 0.18,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
elevation: 4,
borderWidth: StyleSheet.hairlineWidth,
borderColor: Colors.border,
},
recenterText: {
fontSize: 24,
color: Colors.green,
},
});