← 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,
  },
});