From 56809540949b38cd028df89c7fbbd36422ad3d24 Mon Sep 17 00:00:00 2001 From: ellecio2 Date: Thu, 19 Mar 2026 15:33:16 -0400 Subject: [PATCH] feat: Implementar markers nativos de Mapbox con popups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reemplazar Arenarium por markers nativos de Mapbox - Agregar popups con info del POI al hacer clic - Hover effect con sombra (sin escalar) - Remover ícono de búsqueda en MapPointsTab - Estilos CSS para botón de cerrar popup Co-Authored-By: Claude Opus 4.5 --- src/components/admin/MapPointsTab.tsx | 1081 +++++++++++++++++++++++++ src/lib/map/ArenariumMarkers.ts | 215 +++++ 2 files changed, 1296 insertions(+) create mode 100644 src/components/admin/MapPointsTab.tsx create mode 100644 src/lib/map/ArenariumMarkers.ts diff --git a/src/components/admin/MapPointsTab.tsx b/src/components/admin/MapPointsTab.tsx new file mode 100644 index 0000000..3926882 --- /dev/null +++ b/src/components/admin/MapPointsTab.tsx @@ -0,0 +1,1081 @@ +// src/components/admin/MapPointsTab.tsx +// Gestión de puntos de interés (POIs) para el mapa + +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { + MapPin, + Plus, + Trash2, + Edit, + Store, + Search, + ChevronLeft, + ChevronRight, + Coffee, + Beer, + Landmark, + Hotel, + Utensils, + ShoppingBag, + Camera, + Palmtree, + Music, + Heart, + Save, + X, + // Nuevos iconos para categorías adicionales + Building2, + Church, + Cross, + Pill, + Building, + Fuel, + ParkingCircle, + Plane, + Trees, + Theater, + Clapperboard, + Dumbbell, + GraduationCap, + Shield, + Flame, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { useToast } from '@/hooks/use-toast'; +import { apiClient } from '@/services/adminApi'; +import { AdminMap, type AdminMapRef } from '@/components/admin/AdminMap'; +import { Switch } from '@/components/ui/switch'; + +// Categorías de POIs con emojis para el mapa +const POI_CATEGORIES = [ + // Categorías originales + { value: 'monument', label: 'Monumento', icon: Landmark, color: '#8b5cf6', emoji: '🏛️' }, + { value: 'restaurant', label: 'Restaurante', icon: Utensils, color: '#ef4444', emoji: '🍽️' }, + { value: 'cafe', label: 'Cafetería', icon: Coffee, color: '#f59e0b', emoji: '☕' }, + { value: 'bar', label: 'Bar', icon: Beer, color: '#10b981', emoji: '🍺' }, + { value: 'hotel', label: 'Hotel', icon: Hotel, color: '#3b82f6', emoji: '🏨' }, + { value: 'store', label: 'Tienda', icon: Store, color: '#ec4899', emoji: '🛍️' }, + { value: 'shopping', label: 'Centro Comercial', icon: ShoppingBag, color: '#f97316', emoji: '🛒' }, + { value: 'attraction', label: 'Atracción', icon: Camera, color: '#06b6d4', emoji: '📸' }, + { value: 'beach', label: 'Playa', icon: Palmtree, color: '#0ea5e9', emoji: '🏖️' }, + { value: 'nightlife', label: 'Vida Nocturna', icon: Music, color: '#a855f7', emoji: '🎵' }, + { value: 'wellness', label: 'Wellness/Spa', icon: Heart, color: '#14b8a6', emoji: '💆' }, + // Nuevas categorías + { value: 'museum', label: 'Museo', icon: Building2, color: '#7c3aed', emoji: '🏛️' }, + { value: 'church', label: 'Iglesia', icon: Church, color: '#6366f1', emoji: '⛪' }, + { value: 'hospital', label: 'Hospital', icon: Cross, color: '#dc2626', emoji: '🏥' }, + { value: 'pharmacy', label: 'Farmacia', icon: Pill, color: '#16a34a', emoji: '💊' }, + { value: 'bank', label: 'Banco', icon: Building, color: '#1d4ed8', emoji: '🏦' }, + { value: 'gas_station', label: 'Gasolinera', icon: Fuel, color: '#eab308', emoji: '⛽' }, + { value: 'parking', label: 'Estacionamiento', icon: ParkingCircle, color: '#2563eb', emoji: '🅿️' }, + { value: 'airport', label: 'Aeropuerto', icon: Plane, color: '#0284c7', emoji: '✈️' }, + { value: 'park', label: 'Parque', icon: Trees, color: '#22c55e', emoji: '🌳' }, + { value: 'theater', label: 'Teatro', icon: Theater, color: '#be185d', emoji: '🎭' }, + { value: 'cinema', label: 'Cine', icon: Clapperboard, color: '#7c2d12', emoji: '🎬' }, + { value: 'gym', label: 'Gimnasio', icon: Dumbbell, color: '#f97316', emoji: '🏋️' }, + { value: 'university', label: 'Universidad', icon: GraduationCap, color: '#4338ca', emoji: '🎓' }, + { value: 'police', label: 'Policía', icon: Shield, color: '#1e3a8a', emoji: '🚔' }, + { value: 'fire_station', label: 'Bomberos', icon: Flame, color: '#b91c1c', emoji: '🚒' }, + // Categorías naturales + { value: 'natural', label: 'Naturaleza', icon: Trees, color: '#059669', emoji: '🌿' }, + { value: 'viewpoint', label: 'Mirador', icon: Camera, color: '#7dd3fc', emoji: '🔭' }, + { value: 'waterfall', label: 'Cascada', icon: Palmtree, color: '#0891b2', emoji: '💧' }, + { value: 'cave', label: 'Cueva', icon: Landmark, color: '#78716c', emoji: '🕳️' }, + { value: 'mountain', label: 'Montaña', icon: Landmark, color: '#57534e', emoji: '🏔️' }, +] as const; + +type POICategory = typeof POI_CATEGORIES[number]['value']; + +interface POI { + id: string; + name: string; + category: POICategory; + latitude: number; + longitude: number; + address?: string; + country?: string; + description?: string; + phone?: string; + website?: string; + images?: string[]; + isActive: boolean; + isFeatured: boolean; + createdAt: string; + updatedAt: string; +} + +interface NewPOI { + name: string; + category: POICategory; + latitude: number; + longitude: number; + address: string; + description: string; + phone: string; + website: string; + isActive: boolean; + isFeatured: boolean; +} + +const defaultNewPOI: NewPOI = { + name: '', + category: 'attraction', + latitude: 18.4861, + longitude: -69.9312, + address: '', + description: '', + phone: '', + website: '', + isActive: true, + isFeatured: false, +}; + +export const MapPointsTab: React.FC = () => { + const { toast } = useToast(); + const mapRef = useRef(null); + const modalMapRef = useRef(null); + + const [pois, setPois] = useState([]); + const [loading, setLoading] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingPOI, setEditingPOI] = useState(null); + const [newPOI, setNewPOI] = useState(defaultNewPOI); + const [showMapSelector, setShowMapSelector] = useState(false); + const [use3DModels, setUse3DModels] = useState(true); // Activar modelos 3D por defecto + + // Estados para búsqueda y paginación + const [searchQuery, setSearchQuery] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('all'); + const [selectedCountry, setSelectedCountry] = useState('all'); + const [currentPage, setCurrentPage] = useState(1); + const ITEMS_PER_PAGE = 20; + + // Obtener países disponibles + const availableCountries = useMemo(() => { + const countries = new Set(); + pois.forEach((poi) => { + if (poi.country) countries.add(poi.country); + }); + return Array.from(countries).sort(); + }, [pois]); + + // Filtrar POIs + const filteredPois = useMemo(() => { + return pois.filter((poi) => { + const matchesSearch = searchQuery === '' || + poi.name.toLowerCase().includes(searchQuery.toLowerCase()) || + poi.address?.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesCategory = selectedCategory === 'all' || poi.category === selectedCategory; + const matchesCountry = selectedCountry === 'all' || poi.country === selectedCountry; + return matchesSearch && matchesCategory && matchesCountry; + }); + }, [pois, searchQuery, selectedCategory, selectedCountry]); + + // Paginación + const totalPages = Math.ceil(filteredPois.length / ITEMS_PER_PAGE); + const paginatedPois = useMemo(() => { + const start = (currentPage - 1) * ITEMS_PER_PAGE; + return filteredPois.slice(start, start + ITEMS_PER_PAGE); + }, [filteredPois, currentPage]); + + // Reset página cuando cambia el filtro + useEffect(() => { + setCurrentPage(1); + }, [searchQuery, selectedCategory, selectedCountry]); + + // Parsear coordenadas del API - ENFOQUE DIRECTO + // El script de importación envió: "lat,lng" (ej: "18.4285,-68.3892") + // El API puede devolver: string "lat,lng" o Point {x, y} + const parseCoordinates = (coords: any, poiName?: string): { lat: number; lng: number } => { + const DEFAULT = { lat: 18.4861, lng: -69.9312 }; + + if (!coords) { + console.warn(`⚠️ [${poiName}] Sin coordenadas, usando default`); + return DEFAULT; + } + + // CASO 1: String "lat,lng" - formato de importación + if (typeof coords === 'string') { + const parts = coords.split(',').map(s => parseFloat(s.trim())); + if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) { + const [first, second] = parts; + // Validar que sean coordenadas válidas para RD/Caribe + // Lat RD: 17.5 - 20.0, Lng RD: -72.0 - -68.0 + if (first > 0 && first < 25 && second < 0 && second > -80) { + console.log(`✅ [${poiName}] String coords: lat=${first}, lng=${second}`); + return { lat: first, lng: second }; + } else if (second > 0 && second < 25 && first < 0 && first > -80) { + // Están invertidas + console.log(`🔄 [${poiName}] String coords INVERTIDAS: lat=${second}, lng=${first}`); + return { lat: second, lng: first }; + } + } + console.warn(`⚠️ [${poiName}] String inválido: ${coords}`); + return DEFAULT; + } + + // CASO 2: Point PostGIS {x, y} + if (typeof coords === 'object' && coords.x !== undefined && coords.y !== undefined) { + const x = parseFloat(coords.x); + const y = parseFloat(coords.y); + + // PostGIS estándar: x=longitude, y=latitude + // Pero verificamos según valores típicos de RD + if (y > 0 && y < 25 && x < 0 && x > -80) { + // x=lng, y=lat (PostGIS estándar) + console.log(`✅ [${poiName}] Point (estándar): lat=${y}, lng=${x}`); + return { lat: y, lng: x }; + } else if (x > 0 && x < 25 && y < 0 && y > -80) { + // x=lat, y=lng (invertido) + console.log(`🔄 [${poiName}] Point (invertido): lat=${x}, lng=${y}`); + return { lat: x, lng: y }; + } + + console.warn(`⚠️ [${poiName}] Point fuera de rango: x=${x}, y=${y}`); + return DEFAULT; + } + + // CASO 3: Array [lat, lng] + if (Array.isArray(coords) && coords.length === 2) { + const [first, second] = coords.map(Number); + if (first > 0 && first < 25 && second < 0) { + console.log(`✅ [${poiName}] Array: lat=${first}, lng=${second}`); + return { lat: first, lng: second }; + } + } + + console.warn(`⚠️ [${poiName}] Formato desconocido:`, coords); + return DEFAULT; + }; + + // Cargar POIs (usa /tourism/places del API existente) + const loadPOIs = async () => { + try { + setLoading(true); + const data = await apiClient.get('/tourism/places?limit=500'); + + // DEBUG: Ver qué devuelve exactamente el API + console.log('🗺️ API Response for /tourism/places:', data); + console.log('🗺️ Response type:', typeof data); + console.log('🗺️ Is array:', Array.isArray(data)); + console.log('🗺️ Response keys:', data ? Object.keys(data) : 'null'); + + // Handle multiple response formats from the API + let rawList: any[] = []; + if (Array.isArray(data)) { + rawList = data; + } else if (data && typeof data === 'object') { + // Try common API response patterns + rawList = (data as any).data || (data as any).places || (data as any).items || (data as any).results || []; + } + + console.log('🗺️ Parsed rawList length:', rawList.length); + if (rawList.length > 0) { + console.log('🗺️ First item sample:', rawList[0]); + } + + // Mapear datos del API al formato del frontend + const poiList: POI[] = rawList.map((item: any) => { + const coords = parseCoordinates(item.coordinates, item.name); + return { + id: item.id, + name: item.name, + category: item.category || 'attraction', + latitude: coords.lat, + longitude: coords.lng, + address: item.address, + country: item.country || 'República Dominicana', + description: item.description, + phone: item.phone, + website: item.website, + images: item.images ? [item.images] : undefined, + isActive: item.active ?? true, + isFeatured: item.featured ?? false, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + }; + }); + + setPois(poiList); + + // Mostrar POIs en el mapa (Arenarium, 3D CSS o 2D plano) + if (mapRef.current) { + mapRef.current.clearMarkers(); + mapRef.current.clearArenariumMarkers(); + + if (use3DModels) { + // Modo Arenarium (nuevo sistema optimizado) + try { + await mapRef.current.enableArenarium(); + + poiList.forEach((poi: POI) => { + mapRef.current?.addArenariumMarker({ + id: poi.id, + name: poi.name, + category: poi.category, + lat: poi.latitude, + lng: poi.longitude, + address: poi.address, + description: poi.description, + onClick: () => handleCenterOnPOI(poi), + }); + }); + + await mapRef.current.updateArenariumMarkers(); + console.log(`[Arenarium] ${poiList.length} markers agregados al mapa`); + } catch (error) { + console.error('[Arenarium] Error, fallback a 3D CSS:', error); + // Fallback a 3D CSS si Arenarium falla + poiList.forEach((poi: POI) => { + mapRef.current?.add3DMarker( + poi.id, + poi.category, + { lat: poi.latitude, lng: poi.longitude }, + { + size: 'md', + label: poi.name, + animate: true, + onClick: () => handleCenterOnPOI(poi), + } + ); + }); + } + } else { + // Modo 2D con markers tradicionales + poiList.forEach((poi: POI) => { + const category = POI_CATEGORIES.find(c => c.value === poi.category); + mapRef.current?.addMarker(poi.id, { + position: { lat: poi.latitude, lng: poi.longitude }, + color: category?.color || '#6366f1', + icon: category?.emoji, + }); + }); + console.log(`[2D] ${poiList.length} markers agregados al mapa`); + } + } + } catch (error) { + console.error('Error loading POIs:', error); + setPois([]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadPOIs(); + }, []); + + // Recargar POIs cuando cambie el modo 3D/2D + useEffect(() => { + const updateMarkers = async () => { + if (pois.length > 0 && mapRef.current) { + // Limpiar y re-renderizar con el nuevo modo + mapRef.current.clearMarkers(); + mapRef.current.clearArenariumMarkers(); + + if (use3DModels) { + // Modo Arenarium + try { + await mapRef.current.enableArenarium(); + pois.forEach((poi: POI) => { + mapRef.current?.addArenariumMarker({ + id: poi.id, + name: poi.name, + category: poi.category, + lat: poi.latitude, + lng: poi.longitude, + address: poi.address, + description: poi.description, + onClick: () => handleCenterOnPOI(poi), + }); + }); + await mapRef.current.updateArenariumMarkers(); + } catch (error) { + console.error('[Arenarium] Error en toggle:', error); + } + } else { + // Modo 2D + pois.forEach((poi: POI) => { + const category = POI_CATEGORIES.find(c => c.value === poi.category); + mapRef.current?.addMarker(poi.id, { + position: { lat: poi.latitude, lng: poi.longitude }, + color: category?.color || '#6366f1', + icon: category?.emoji, + }); + }); + } + } + }; + updateMarkers(); + }, [use3DModels]); // eslint-disable-line react-hooks/exhaustive-deps + + // Crear/Actualizar POI (usa /tourism/places del API) + const handleSavePOI = async () => { + if (!newPOI.name.trim()) { + toast({ title: 'Error', description: 'El nombre es requerido', variant: 'destructive' }); + return; + } + + // Convertir formato del frontend al formato del API + const apiData = { + name: newPOI.name, + category: newPOI.category, + coordinates: `${newPOI.latitude},${newPOI.longitude}`, // API usa "lat,lng" string + address: newPOI.address, + description: newPOI.description, + phone: newPOI.phone, + website: newPOI.website, + active: newPOI.isActive, + }; + + try { + if (editingPOI) { + await apiClient.patch(`/tourism/places/${editingPOI.id}`, apiData); + toast({ title: 'POI actualizado', description: `${newPOI.name} ha sido actualizado` }); + } else { + await apiClient.post('/tourism/places', apiData); + toast({ title: 'POI creado', description: `${newPOI.name} ha sido creado` }); + } + + setDialogOpen(false); + setEditingPOI(null); + setNewPOI(defaultNewPOI); + setShowMapSelector(false); + loadPOIs(); + } catch (error: any) { + console.error('Error saving POI:', error); + toast({ + title: 'Error', + description: error?.message || 'No se pudo guardar el POI', + variant: 'destructive' + }); + } + }; + + // Eliminar POI + const handleDeletePOI = async (poi: POI) => { + try { + await apiClient.delete(`/tourism/places/${poi.id}`); + toast({ title: 'POI eliminado', description: `${poi.name} ha sido eliminado` }); + loadPOIs(); + } catch (error: any) { + console.error('Error deleting POI:', error); + toast({ + title: 'Error', + description: error?.message || 'No se pudo eliminar el POI', + variant: 'destructive' + }); + } + }; + + // Editar POI + const handleEditPOI = (poi: POI) => { + setEditingPOI(poi); + setNewPOI({ + name: poi.name, + category: poi.category, + latitude: poi.latitude, + longitude: poi.longitude, + address: poi.address || '', + description: poi.description || '', + phone: poi.phone || '', + website: poi.website || '', + isActive: poi.isActive, + isFeatured: poi.isFeatured, + }); + setShowMapSelector(false); + setDialogOpen(true); + }; + + // Reverse geocoding para obtener dirección desde coordenadas + const reverseGeocode = async (lat: number, lng: number): Promise => { + const mapboxToken = import.meta.env.VITE_MAPBOX_TOKEN; + if (!mapboxToken) return ''; + + try { + const response = await fetch( + `https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${mapboxToken}&language=es` + ); + const data = await response.json(); + + if (data.features && data.features.length > 0) { + // Buscar la dirección más específica (place_name incluye la dirección completa) + return data.features[0].place_name || ''; + } + return ''; + } catch (error) { + console.error('Error en reverse geocoding:', error); + return ''; + } + }; + + // Manejar click en el mapa del modal (selector de ubicación) + const handleModalMapClick = async (coords: { lat: number; lng: number }) => { + // Actualizar coordenadas inmediatamente + setNewPOI(prev => ({ + ...prev, + latitude: coords.lat, + longitude: coords.lng, + })); + + // Mostrar marker temporal en el mapa del modal + if (modalMapRef.current) { + modalMapRef.current.clearMarkers(); + modalMapRef.current.addMarker('selected-location', { + position: coords, + color: '#f59e0b', + label: 'NEW', + }); + } + + // Obtener dirección por reverse geocoding + toast({ title: 'Obteniendo dirección...', description: 'Buscando información del lugar' }); + const address = await reverseGeocode(coords.lat, coords.lng); + + if (address) { + setNewPOI(prev => ({ + ...prev, + address: address, + })); + toast({ title: 'Ubicación seleccionada', description: address }); + } else { + toast({ title: 'Ubicación seleccionada', description: `Lat: ${coords.lat.toFixed(7)}, Lng: ${coords.lng.toFixed(7)}` }); + } + }; + + // Centrar mapa en POI + const handleCenterOnPOI = (poi: POI) => { + if (mapRef.current) { + mapRef.current.flyTo({ lat: poi.latitude, lng: poi.longitude }, 19); + } + }; + + const getCategoryInfo = (category: POICategory) => { + return POI_CATEGORIES.find(c => c.value === category) || POI_CATEGORIES[0]; + }; + + // TEST: Agregar marcadores de prueba con coordenadas conocidas + const addTestMarkers = () => { + if (!mapRef.current) return; + + mapRef.current.clearMarkers(); + + // Coordenadas verificadas de lugares conocidos en RD + const testPOIs = [ + { id: 'test-1', name: 'Zona Colonial (Santo Domingo)', lat: 18.4735, lng: -69.8838, category: 'monument' }, + { id: 'test-2', name: 'Playa Bávaro', lat: 18.6823, lng: -68.4234, category: 'beach' }, + { id: 'test-3', name: 'Cap Cana Marina', lat: 18.4267, lng: -68.3923, category: 'attraction' }, + { id: 'test-4', name: 'Altos de Chavón', lat: 18.4234, lng: -68.9567, category: 'attraction' }, + { id: 'test-5', name: 'Pico Duarte', lat: 19.0167, lng: -70.9833, category: 'natural' }, + ]; + + console.log('🧪 Agregando marcadores de prueba...'); + testPOIs.forEach((poi) => { + console.log(`📍 TEST: ${poi.name} -> lat=${poi.lat}, lng=${poi.lng}`); + mapRef.current?.add3DMarker( + poi.id, + poi.category, + { lat: poi.lat, lng: poi.lng }, + { size: 'lg', label: poi.name, animate: true } + ); + }); + + // Centrar en RD + mapRef.current.flyTo({ lat: 18.7, lng: -69.5 }, 7); + toast({ title: '🧪 Test', description: '5 marcadores de prueba agregados' }); + }; + + return ( +
+
+
+

Puntos de Interés

+

+ Gestiona los puntos que aparecerán en el mapa para los turistas +

+
+
+ {pois.length} POIs + {pois.filter(p => p.isActive).length} Activos + + {/* Botón de prueba */} + + + {/* Toggle 3D/2D */} +
+ 2D + + 3D +
+ + { setDialogOpen(open); if (!open) setShowMapSelector(false); }}> + + + + + + + {editingPOI ? 'Editar Punto de Interés' : 'Nuevo Punto de Interés'} + + +
+ {/* Nombre */} +
+ + setNewPOI({ ...newPOI, name: e.target.value })} + placeholder="Ej: Monumento a Colón" + /> +
+ + {/* Categoría */} +
+ + +
+ + {/* Ubicación */} +
+ +
+
+ + setNewPOI({ ...newPOI, latitude: parseFloat(e.target.value) || 0 })} + /> +
+
+ + setNewPOI({ ...newPOI, longitude: parseFloat(e.target.value) || 0 })} + /> +
+
+ + + {/* Mapa de selección dentro del modal */} + {showMapSelector && ( +
+ { + // Mostrar marker de la ubicación actual si existe + if (newPOI.latitude && newPOI.longitude) { + adapter.addMarker('selected-location', { + position: { lat: newPOI.latitude, lng: newPOI.longitude }, + color: '#f59e0b', + label: 'POI', + }); + } + }} + /> +

+ Haz clic en el mapa para seleccionar la ubicación +

+
+ )} +
+ + {/* Dirección */} +
+ + setNewPOI({ ...newPOI, address: e.target.value })} + placeholder="Ej: Calle El Conde #123, Zona Colonial" + /> +
+ + {/* Descripción */} +
+ +