Files
SIB/frontend/src/views/MapView.vue

2150 lines
60 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { onMounted, ref, watch, nextTick, onUnmounted, computed } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useRouteStore } from "@/stores/route";
import { useMapStore } from "@/stores/map";
import { useBusStopStore } from "@/stores/busStop";
import { useCouponStore } from "@/stores/coupon";
import { useGoogleMaps } from "@/composables/useGoogleMaps";
import { telemetryService } from "@/services/telemetryService";
import { analyticsService } from "@/services/analyticsService";
import { getImageUrl } from "@/utils/imageUrl";
import BusStopInfoModal from "@/components/BusStopInfoModal.vue";
import type { BusStop } from '@/types'
const router = useRouter();
const { t } = useI18n();
const routeStore = useRouteStore();
const mapStore = useMapStore();
const busStopStore = useBusStopStore();
const couponStore = useCouponStore();
const { map, isLoaded, error: mapsError, initMap, addNumberedMarker, addHtmlMarker, fitBounds, setCenter, setZoom, addMarker, clearAllOverlays } = useGoogleMaps();
const markers = ref<any[]>([]);
const promoMarkers = ref<any[]>([]);
const userMarker = ref<any>(null);
const polyline = ref<google.maps.Polyline | null>(null);
const walkingPolyline = ref<google.maps.Polyline | null>(null);
const walkingPolylineBorder = ref<google.maps.Polyline | null>(null); // Borde blanco estilo Google Maps
const optimalStopPulse = ref<any>(null); // Radar para la parada óptima
const navigationInfo = ref<{ distance: string, duration: string, targetName: string } | null>(null);
const showRouteDropdown = ref(false);
const routeCardRef = ref<HTMLElement | null>(null);
const isUpdatingMarkers = ref(false);
const unitMarkers = ref<Map<string, google.maps.Marker>>(new Map());
const unitFetchInterval = ref<any>(null);
const userCoords = ref<{ lat: number; lng: number } | null>(null); // Store last user location for internal navigation
const currentMarkerMode = ref<'dot' | 'pin' | null>(null);
const mappingSequenceId = ref(0); // Atomic ID to prevent race conditions
// Search state
const stopSearchQuery = ref("");
const destinationQuery = ref("");
const originQuery = ref("Mi ubicación");
const filteredSearchResults = ref<BusStop[]>([]);
const showSearchDropdown = ref(false);
const showUberSearch = ref(false);
const showRoutesToggle = ref(false);
const showPromos = ref(false);
const isInputFocused = ref(false);
function onInputFocus() {
isInputFocused.value = true;
}
function onInputBlur() {
isInputFocused.value = false;
}
watch([stopSearchQuery, destinationQuery], ([stopQuery, destQuery]) => {
const query = showUberSearch.value ? destQuery : stopQuery;
if (query.trim().length > 0) {
filteredSearchResults.value = busStopStore.busStops.filter(s =>
s.name.toLowerCase().includes(query.toLowerCase())
).slice(0, 5);
showSearchDropdown.value = true;
} else {
filteredSearchResults.value = [];
showSearchDropdown.value = false;
}
});
function selectStopFromSearch(stop: BusStop) {
setCenter(stop.latitude, stop.longitude);
setZoom(17);
handleBusStopClick(stop);
stopSearchQuery.value = "";
destinationQuery.value = "";
showSearchDropdown.value = false;
showUberSearch.value = false;
}
function openUberSearch() {
showUberSearch.value = true;
}
function closeUberSearch() {
showUberSearch.value = false;
destinationQuery.value = "";
}
async function clearAllMapData() {
console.log('🤖 JARVIS: Iniciando PURGA nuclear con tolerancia a fallos...');
// 1. Respuesta inmediata en UI
showUberSearch.value = false;
showRoutesToggle.value = false;
destinationQuery.value = "";
stopSearchQuery.value = "";
navigationInfo.value = null;
// 2. Invalidar cualquier hilo de dibujo en curso
mappingSequenceId.value++;
try {
// 3. Resetear Store
routeStore.clearSelection();
lastProcessedRouteId.value = null;
// 4. Limpieza manual protegida de marcadores
const sweep = (arrayRef: any) => {
if (!arrayRef.value) return;
arrayRef.value.forEach((m: any) => {
try { if (m && m.setMap) m.setMap(null); } catch (e) {}
});
arrayRef.value = [];
};
sweep(markers);
sweep(promoMarkers);
// Limpiar Unidades de transporte
if (unitMarkers.value) {
unitMarkers.value.forEach((m: any) => {
try { if (m && m.setMap) m.setMap(null); } catch (e) {}
});
unitMarkers.value.clear();
}
// 5. Barrido profundo de Google
if (typeof clearAllOverlays === 'function') {
try { clearAllOverlays(); } catch (e) {}
}
// 6. Limpiar polilíneas y pulsos
if (polyline.value) { polyline.value.setMap(null); polyline.value = null; }
if (walkingPolyline.value) { walkingPolyline.value.setMap(null); walkingPolyline.value = null; }
if (optimalStopPulse.value) {
try { if (optimalStopPulse.value.setMap) optimalStopPulse.value.setMap(null); } catch(e){}
optimalStopPulse.value = null;
}
// 7. Restaurar Solo Usuario tras un breve respiro
await nextTick();
if (userCoords.value) {
const { lat, lng } = userCoords.value;
if (userMarker.value && userMarker.value.setMap) {
try { userMarker.value.setMap(null); } catch(e){}
}
userMarker.value = addHtmlMarker({ lat, lng }, sonarHtml, { x: -30, y: -30 });
}
console.log('🤖 JARVIS: Purga completada con éxito.');
} catch (err) {
console.error('❌ JARVIS: Error crítico en purga, pero el mapa debería estar limpio:', err);
}
}
// Modal state
const showBusStopModal = ref(false);
const selectedBusStop = ref<BusStop | null>(null);
const showPromoModal = ref(false);
const selectedPromo = ref<any>(null);
// Close dropdown when clicking outside
function handleClickOutside(event: MouseEvent) {
if (routeCardRef.value && !routeCardRef.value.contains(event.target as Node)) {
showRouteDropdown.value = false;
}
}
function handleBusStopClick(stop: BusStop) {
selectedBusStop.value = stop;
showBusStopModal.value = true;
}
function closeBusStopModal() {
showBusStopModal.value = false;
selectedBusStop.value = null;
}
function handlePromoClick(promo: any) {
selectedPromo.value = promo;
showPromoModal.value = true;
}
function closePromoModal() {
showPromoModal.value = false;
selectedPromo.value = null;
}
async function claimPromo() {
if (!selectedPromo.value) return
try {
await couponStore.claimCoupon(selectedPromo.value.id)
alert('¡Promoción reclamada con éxito! Revisa "Mis Cupones" en tu perfil.')
closePromoModal()
} catch (e: any) {
alert(e.message || 'Error al reclamar la promoción')
}
}
onMounted(async () => {
analyticsService.logEvent({ event_name: 'screen_view', properties: { screen_name: 'Map' } })
// Add click outside listener
document.addEventListener('click', handleClickOutside);
// Load routes, bus stops and promos
await routeStore.loadRoutes();
await couponStore.loadCoupons({ active_only: true });
// Sync from query params if coming from Schedules or external link
const queryRouteId = router.currentRoute.value.query.routeId as string;
if (queryRouteId && queryRouteId !== routeStore.selectedRouteId) {
const foundRoute = routeStore.allRoutes.find(r => r.id === queryRouteId);
if (foundRoute) {
// Use selectRoute to load stops and update store
await routeStore.selectRoute(foundRoute.id, foundRoute.name);
}
}
// Handle stopId if present
const queryStopId = router.currentRoute.value.query.stopId as string;
if (queryStopId) {
await busStopStore.loadBusStops();
const foundStop = busStopStore.busStops.find(s => s.id === queryStopId);
if (foundStop) {
selectedBusStop.value = foundStop;
showBusStopModal.value = true;
setCenter(foundStop.latitude, foundStop.longitude);
setZoom(17);
}
}
// Wait for Google Maps to load
if (isLoaded.value) {
await initializeMap();
} else {
// Watch for when maps are loaded
const unwatch = watch(isLoaded, async (loaded) => {
if (loaded) {
await initializeMap();
unwatch();
}
});
}
// Start periodic fetch of active units
unitFetchInterval.value = setInterval(updateActiveUnits, 15000);
updateActiveUnits();
// Carousel auto-slide
startCarousel();
});
const currentCarouselIndex = ref(0);
const currentPromo = computed(() => {
if (couponStore.coupons.length === 0) return null;
// Ensure we don't exceed bounds
const idx = currentCarouselIndex.value % couponStore.coupons.length;
return couponStore.coupons[idx];
});
const carouselTimer = ref<any>(null);
function startCarousel() {
if (carouselTimer.value) clearInterval(carouselTimer.value);
carouselTimer.value = setInterval(() => {
if (couponStore.coupons.length > 0) {
currentCarouselIndex.value = (currentCarouselIndex.value + 1) % couponStore.coupons.length;
}
}, 5000);
}
function stopCarousel() {
if (carouselTimer.value) clearInterval(carouselTimer.value);
}
function nextPromo() {
currentCarouselIndex.value = (currentCarouselIndex.value + 1) % couponStore.coupons.length;
startCarousel();
}
function prevPromo() {
currentCarouselIndex.value = (currentCarouselIndex.value - 1 + couponStore.coupons.length) % couponStore.coupons.length;
startCarousel();
}
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
if (unitFetchInterval.value) clearInterval(unitFetchInterval.value);
if (carouselTimer.value) clearInterval(carouselTimer.value);
// Clear all markers when component unmounts
clearMapMarkers();
// Clear unit markers
unitMarkers.value.forEach(m => m.setMap(null));
unitMarkers.value.clear();
});
async function initializeMap() {
// Wait for DOM to be ready
await nextTick();
// Small delay to ensure the element is rendered
await new Promise(resolve => setTimeout(resolve, 100));
initMap("map", mapStore.center, mapStore.zoom);
if (map.value) {
map.value.addListener('zoom_changed', () => {
updateMarkersStyles();
});
}
// If we have a selected route, show its stops
if (routeStore.selectedRouteId && routeStore.selectedRouteStops.length > 0) {
updateMapMarkers();
}
// Show promotions on the map
updatePromoMarkers();
// Apply initial styles based on current zoom
updateMarkersStyles(true);
}
// Watch for route selection changes
watch(
() => routeStore.selectedRouteId,
async (routeId, oldRouteId) => {
// ALWAYS clear markers first when route changes - do this immediately
if (oldRouteId !== routeId) {
console.log(`Route changing from ${oldRouteId} to ${routeId} - clearing markers`)
lastProcessedRouteId.value = routeId; // Update before clearing
clearMapMarkers();
// Wait a bit to ensure markers are fully removed
await nextTick();
await new Promise(resolve => setTimeout(resolve, 150));
}
if (routeId) {
// Route stops are automatically loaded when route is selected
// Update map markers for the selected route
await updateMapMarkers();
} else {
// Clear markers when no route is selected
lastProcessedRouteId.value = null;
clearMapMarkers();
}
},
{ immediate: false }
);
// Track the last route ID to prevent double updates
const lastProcessedRouteId = ref<string | null>(null);
// Watch for route stops changes - but only if route ID hasn't changed
// This prevents double updates when both watchers fire
watch(
() => routeStore.selectedRouteStops,
async (newStops, oldStops) => {
const currentRouteId = routeStore.selectedRouteId;
// Skip if route ID was just changed (the routeId watcher will handle it)
if (currentRouteId === lastProcessedRouteId.value) {
// Only update if route is selected and map is loaded
// Skip if we're already updating or if stops haven't actually changed
if (currentRouteId && isLoaded.value && !isUpdatingMarkers.value) {
// Check if stops actually changed
if (!oldStops || newStops.length !== oldStops.length ||
newStops.some((stop, idx) => stop.id !== oldStops[idx]?.id)) {
console.log('Route stops changed - updating markers')
await updateMapMarkers();
// FLOW REFINEMENT: After markers are loaded, find the optimal entrance stop
if (newStops.length > 0) {
highlightOptimalStopForRoute();
}
}
}
}
},
{ deep: true }
);
function clearMapMarkers() {
console.log('clearMapMarkers called - clearing bus stop markers')
// Do NOT call clearAllOverlays() here as it wipes out EVERYTHING (including POIs)
// Instead, clear only the markers we track locally for routes
// Also clear our local tracking and ensure markers are removed
const markerCount = markers.value.length
markers.value.forEach((marker: any) => {
if (marker) {
// Remove marker from map
if (marker.setMap) {
marker.setMap(null);
}
// Also try to remove it if it has a remove method
if (typeof marker.remove === 'function') {
marker.remove();
}
}
});
// Clear the array
markers.value = [];
console.log(`Cleared ${markerCount} local markers`)
// Clear polyline
if (polyline.value) {
polyline.value.setMap(null);
polyline.value = null;
}
// Clear walking polyline
if (walkingPolyline.value) {
walkingPolyline.value.setMap(null);
walkingPolyline.value = null;
}
if (walkingPolylineBorder.value) {
walkingPolylineBorder.value.setMap(null);
walkingPolylineBorder.value = null;
}
// Clear navigation info
navigationInfo.value = null;
// Clear optimal pulse
if (optimalStopPulse.value) {
if (typeof optimalStopPulse.value.setMap === 'function') {
optimalStopPulse.value.setMap(null);
}
optimalStopPulse.value = null;
}
}
async function updateMapMarkers() {
if (!isLoaded.value) return;
// Incrementar ID de secuencia para invalidar dibujos previos
mappingSequenceId.value++;
const thisSeq = mappingSequenceId.value;
const currentRequestRouteId = routeStore.selectedRouteId;
const stops = [...routeStore.selectedRouteStops];
if (!currentRequestRouteId || stops.length === 0) {
clearMapMarkers();
return;
}
isUpdatingMarkers.value = true;
console.log(`🤖 JARVIS: Iniciando dibujo de markers (Secuencia: ${thisSeq})`)
try {
await nextTick();
await new Promise(resolve => setTimeout(resolve, 30));
// Abortar si la secuencia cambió durante la espera
if (mappingSequenceId.value !== thisSeq || !routeStore.selectedRouteId) {
return;
}
const newMarkers: any[] = [];
const path: Array<{ lat: number; lng: number }> = [];
for (let i = 0; i < stops.length; i++) {
const stop = stops[i];
if (!stop) continue;
// Verificación atómica en cada paso
if (mappingSequenceId.value !== thisSeq || !routeStore.selectedRouteId) {
newMarkers.forEach(m => { if (m.setMap) m.setMap(null); });
return;
}
const marker = addNumberedMarker(
{ lat: stop.latitude, lng: stop.longitude },
i + 1,
stop.name,
() => handleBusStopClick(stop)
);
if (marker) newMarkers.push(marker);
path.push({ lat: stop.latitude, lng: stop.longitude });
}
// Final check before committing to the map
if (mappingSequenceId.value !== thisSeq || !routeStore.selectedRouteId) {
newMarkers.forEach(m => { if (m.setMap) m.setMap(null); });
return;
}
clearMapMarkers();
markers.value = newMarkers;
if (path.length > 0) fitBounds(path);
} catch (err) {
console.error('❌ JARVIS: Error en updateMapMarkers:', err);
} finally {
if (mappingSequenceId.value === thisSeq) {
isUpdatingMarkers.value = false;
if (routeStore.selectedRouteId) updateMarkersStyles(true);
}
}
}
/**
* Optimización de rendimiento: Solo actualiza los iconos si cambiamos de modo (punto vs pin)
* o si se fuerza la actualización (ej: al cargar nueva ruta)
*/
function updateMarkersStyles(force = false) {
if (!map.value || markers.value.length === 0 || !routeStore.selectedRouteId) return;
const currentZoom = map.value.getZoom() || 12;
const newMode = currentZoom >= 15 ? 'pin' : 'dot';
if (!force && currentMarkerMode.value === newMode) return;
currentMarkerMode.value = newMode;
const showNumbers = newMode === 'pin';
console.log(`🤖 JARVIS: Actualizando estilos de marcadores a modo: ${newMode}`);
markers.value.forEach((marker: any, index: number) => {
if (!marker) return;
// Si la secuencia cambió o la ruta desapareció mientras hacíamos esto, abortamos
if (!routeStore.selectedRouteId) {
if (marker.setMap) marker.setMap(null);
return;
}
if (showNumbers) {
// MODO PREMIUM: Círculo Amarillo con Borde Negro y Numero Negro
marker.setIcon({
path: google.maps.SymbolPath.CIRCLE,
fillColor: '#FEE715',
fillOpacity: 1,
strokeColor: '#101820',
strokeWeight: 2.5,
scale: 14, // Tamaño ideal para leer el número dentro
});
marker.setLabel({
text: (index + 1).toString(),
color: '#101820',
fontSize: '13px',
fontWeight: '900',
});
} else {
// MODO COMPACTO: Punto Amarillo brillante con anillo de profundidad
marker.setIcon({
path: google.maps.SymbolPath.CIRCLE,
fillColor: '#FEE715',
fillOpacity: 1,
strokeColor: '#101820',
strokeWeight: 1.5,
scale: 7,
});
marker.setLabel(null);
}
});
}
// La función drawRouteOnRoad ha sido eliminada por petición del usuario (línea azul removida)
async function updatePromoMarkers() {
if (!isLoaded.value) return;
// ALWAYS clear existing promo markers first
promoMarkers.value.forEach(m => m.setMap(null));
promoMarkers.value = [];
// Only show coupons that have a business with coordinates
const promosWithCoords = couponStore.coupons.filter(c =>
c.is_active && c.business && c.business.latitude && c.business.longitude
);
console.log(`Adding ${promosWithCoords.length} promo markers`);
promosWithCoords.forEach(promo => {
const marker = addMarker(
{ lat: promo.business!.latitude!, lng: promo.business!.longitude! },
{
title: promo.title,
icon: {
path: "M20 6h-2.18c.11-.31.18-.65.18-1 0-1.66-1.34-3-3-3-1.05 0-1.96.54-2.5 1.35l-.5.65-.5-.65C10.96 2.54 10.05 2 9 2 7.34 2 6 3.34 6 5c0 .35.07.69.18 1H4c-1.11 0-1.99.89-1.99 2L2 19c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2zm-5-2c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zM9 4c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm11 15H4v-2h16v2zm0-5H4V8h5.08L7 10.83 8.62 12 11 8.76l1-1.36 1 1.36L15.38 12 17 10.83 14.92 8H20v6z",
fillColor: '#FF4081', // Pinkish/Red for promos
fillOpacity: 1,
strokeColor: '#FFFFFF',
strokeWeight: 2,
anchor: new google.maps.Point(12, 12),
scale: 2
}
}
);
if (marker) {
marker.addListener('click', () => handlePromoClick(promo));
promoMarkers.value.push(marker);
}
});
}
function selectRouteAndClose(routeId: string, routeName: string) {
console.log(`🤖 JARVIS: Iniciando viaje hacia ${routeName}`);
routeStore.selectRoute(routeId, routeName);
showRouteDropdown.value = false;
showUberSearch.value = false; // Close the expanded search panel
// Si no tenemos ubicación, la pedimos para poder calcular la parada óptima automáticamente
if (!userCoords.value) {
locateUser();
}
}
async function updateActiveUnits() {
if (!isLoaded.value) return;
try {
// No-op for now. Backend is purely Supabase now.
} catch (e) {
console.error('Failed to update active units', e);
}
}
const sonarHtml = `
<div style="position: relative; width: 60px; height: 60px; display: flex; align-items: center; justify-content: center;">
<div style="position: absolute; width: 100%; height: 100%; border-radius: 50%; background-color: rgba(0, 212, 255, 0.5); animation: sonar-pulse 2.5s infinite ease-out;"></div>
<div style="position: absolute; width: 100%; height: 100%; border-radius: 50%; background-color: rgba(0, 212, 255, 0.3); animation: sonar-pulse 2.5s infinite ease-out; animation-delay: 1.25s;"></div>
<div style="width: 16px; height: 16px; background-color: #00d4ff; border-radius: 50%; box-shadow: 0 0 20px #00d4ff, 0 0 40px rgba(0, 212, 255, 0.6); border: 2px solid white; z-index: 2;"></div>
<style>
@keyframes sonar-pulse {
0% { transform: scale(0.1); opacity: 0.8; }
100% { transform: scale(4); opacity: 0; }
}
</style>
</div>
`;
const optimalSonarHtml = `
<div style="position: relative; width: 60px; height: 60px; display: flex; align-items: center; justify-content: center;">
<div style="position: absolute; width: 100%; height: 100%; border-radius: 50%; background-color: rgba(255, 165, 0, 0.5); animation: sonar-pulse 2.5s infinite ease-out;"></div>
<div style="position: absolute; width: 100%; height: 100%; border-radius: 50%; background-color: rgba(255, 165, 0, 0.3); animation: sonar-pulse 2.5s infinite ease-out; animation-delay: 1.25s;"></div>
<div style="width: 16px; height: 16px; background-color: #FFA500; border-radius: 50%; box-shadow: 0 0 20px #FFA500, 0 0 40px rgba(255, 165, 0, 0.6); border: 2px solid white; z-index: 2;"></div>
</div>
`;
function locateUser() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords;
setCenter(latitude, longitude);
setZoom(16);
// Remove existing user marker/sonar if any
if (userMarker.value) {
if (typeof userMarker.value.setMap === 'function') {
userMarker.value.setMap(null);
}
}
// Guardamos la ubicación para navegaciones futuras
userCoords.value = { lat: latitude, lng: longitude };
// Add the CELESTE SONAR using HTML Marker
// Offset is negative half of the container size (60px/2 = 30)
userMarker.value = addHtmlMarker(
{ lat: latitude, lng: longitude },
sonarHtml,
{ x: -30, y: -30 }
);
},
(error) => {
console.error("Error getting location", error);
alert("No se pudo obtener tu ubicación. Por favor, verifica tus permisos de GPS.");
}
);
}
}
/**
* CÁLCULO DE INTELIGENCIA VIAL:
* Encuentra la parada más cercana dentro de la ruta seleccionada
* y la resalta para el usuario.
*/
function highlightOptimalStopForRoute() {
if (!userCoords.value || routeStore.selectedRouteStops.length === 0) {
console.warn('🤖 JARVIS: Sin ubicación o paradas para calcular parada óptima.');
return;
}
console.log('🤖 JARVIS: Calculando punto de abordaje óptimo sobre la ruta...');
let nearestStop = null;
let minDistance = Infinity;
const getDistance = (l1: any, l2: any) => {
const R = 6371; // Radio de la Tierra en km
const dLat = (l2.lat - l1.lat) * Math.PI / 180;
const dLon = (l2.lng - l1.lng) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(l1.lat * Math.PI / 180) * Math.cos(l2.lat * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
};
routeStore.selectedRouteStops.forEach(stop => {
const dist = getDistance(userCoords.value, { lat: stop.latitude, lng: stop.longitude });
if (dist < minDistance) {
minDistance = dist;
nearestStop = stop;
}
});
if (nearestStop) {
const stopObj = nearestStop as BusStop;
console.log(`🤖 JARVIS: Parada óptima detectada: ${stopObj.name} (${minDistance.toFixed(2)} km)`);
// Centrar mapa en la parada para guiar al usuario
setCenter(stopObj.latitude, stopObj.longitude);
setZoom(17);
// Añadir el PULSO NARANJA de "Aborda aquí"
if (optimalStopPulse.value && typeof optimalStopPulse.value.setMap === 'function') {
optimalStopPulse.value.setMap(null);
}
optimalStopPulse.value = addHtmlMarker(
{ lat: stopObj.latitude, lng: stopObj.longitude },
optimalSonarHtml,
{ x: -30, y: -30 }
);
// Mini-notificación informativa
navigationInfo.value = {
distance: minDistance < 1 ? `${(minDistance * 1000).toFixed(0)} m` : `${minDistance.toFixed(1)} km`,
duration: "Más cercana",
targetName: stopObj.name
};
}
}
/**
* Dibuja la ruta caminando en el mapa interno sin abrir apps externas
*/
function drawInternalWalkingRoute(targetStop: BusStop, originOverride?: { lat: number, lng: number }) {
const origin = originOverride || userCoords.value;
if (!origin) {
// Si no tenemos ubicación, la pedimos
navigator.geolocation.getCurrentPosition((pos) => {
userCoords.value = { lat: pos.coords.latitude, lng: pos.coords.longitude };
calculateWalkingPath(userCoords.value, targetStop);
}, (_err) => {
alert("Necesitamos tu ubicación para trazar la ruta a pie.");
});
} else {
calculateWalkingPath(origin, targetStop);
}
}
function calculateWalkingPath(origin: { lat: number, lng: number }, targetStop: BusStop) {
// 1. Limpiar pulso anterior si existe
if (optimalStopPulse.value) {
if (typeof optimalStopPulse.value.setMap === 'function') {
optimalStopPulse.value.setMap(null);
}
optimalStopPulse.value = null;
}
// 2. Añadir el PULSO NARANJA de "Parada Óptima"
optimalStopPulse.value = addHtmlMarker(
{ lat: targetStop.latitude, lng: targetStop.longitude },
optimalSonarHtml,
{ x: -30, y: -30 }
);
// 3. Resaltar la parada en verde (marcador estándar)
const targetStops = routeStore.selectedRouteId ? routeStore.selectedRouteStops : busStopStore.busStops;
const stopIndex = targetStops.findIndex(s => s.id === targetStop.id);
if (stopIndex !== -1 && markers.value[stopIndex]) {
const marker = markers.value[stopIndex];
marker.setIcon({
path: currentMarkerMode.value === 'pin'
? "M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"
: google.maps.SymbolPath.CIRCLE,
fillColor: '#4CAF50',
fillOpacity: 1,
strokeColor: '#FFFFFF',
strokeWeight: 2,
scale: currentMarkerMode.value === 'pin' ? 2.5 : 9,
anchor: currentMarkerMode.value === 'pin' ? new google.maps.Point(12, 22) : null
});
}
// 2. Trazar línea de puntos verde siguiendo RED VIAL PRINCIPAL
const directionsService = new google.maps.DirectionsService();
directionsService.route({
origin: origin,
destination: { lat: targetStop.latitude, lng: targetStop.longitude },
travelMode: google.maps.TravelMode.DRIVING,
}, (dirResult, dirStatus) => {
if (dirStatus === 'OK' && dirResult && dirResult.routes && dirResult.routes[0]) {
const route = dirResult.routes[0];
const leg = route.legs?.[0];
// Guardar info de navegación (ETA y Distancia)
if (leg) {
navigationInfo.value = {
distance: leg.distance?.text || '---',
duration: leg.duration?.text || '---',
targetName: targetStop.name
};
}
if (walkingPolyline.value) walkingPolyline.value.setMap(null);
if (walkingPolylineBorder.value) walkingPolylineBorder.value.setMap(null);
// CAPA 1: Borde blanco (Para dar contraste estilo Google Maps)
walkingPolylineBorder.value = new google.maps.Polyline({
path: route.overview_path,
geodesic: true,
strokeColor: '#FFFFFF',
strokeOpacity: 0.9,
strokeWeight: 10, // Un poco más grueso para el borde
map: map.value,
zIndex: 5
});
// CAPA 2: Línea Indigo Central (La ruta principal)
walkingPolyline.value = new google.maps.Polyline({
path: route.overview_path,
geodesic: true,
strokeColor: '#4285F4', // Azul Google Maps
strokeOpacity: 1.0,
strokeWeight: 5,
map: map.value,
zIndex: 10
});
// Ajustar zoom para mostrar toda la ruta de caminata
if (map.value) {
const bounds = new google.maps.LatLngBounds();
route.overview_path.forEach(p => bounds.extend(p));
map.value.fitBounds(bounds, { top: 100, bottom: 200, left: 50, right: 50 });
}
}
});
}
function clearNavigation() {
clearMapMarkers();
navigationInfo.value = null;
}
</script>
<template>
<div class="split-view">
<!-- Main Map Container -->
<div class="map-side">
<div class="map-view">
<div class="map-container">
<div v-if="mapsError" class="error">
<div style="text-align: center; padding: 20px; max-width: 600px; margin: 0 auto;">
<h3 style="color: var(--text-primary); margin-bottom: 15px;"> Error al cargar mapa</h3>
<div style="color: var(--text-primary); margin-bottom: 15px; white-space: pre-line; text-align: left; background: var(--bg-secondary); padding: 15px; border-radius: 8px;">
{{ mapsError }}
</div>
</div>
</div>
<div v-else-if="!isLoaded" class="loading">
<p>{{ t('map.loadingMap') }}</p>
</div>
<!-- Always render map div so it exists in DOM -->
<div id="map" class="map" :style="{ display: isLoaded ? 'block' : 'none' }"></div>
<!-- Floating UI Elements -->
<div class="map-floating-controls">
<!-- Botón de Ofertas (FAB Simple) -->
<button
v-if="isLoaded"
class="offers-fab pulse"
:class="{ 'active': showPromos }"
@click="showPromos = !showPromos"
>
<span class="material-icons">
{{ showPromos ? 'close' : 'local_offer' }}
</span>
<span v-if="couponStore.coupons.length > 0 && !showPromos" class="offers-badge">
{{ couponStore.coupons.length }}
</span>
</button>
<!-- Location Button (Animated Pin) -->
<button
v-if="isLoaded"
class="location-loader-btn"
@click="locateUser"
:title="t('map.showMyLocation')"
>
<span class="material-icons">my_location</span>
</button>
</div>
</div>
<!-- Uber-like Search Interface -->
<div class="uber-search-container" :class="{ 'compact-mode': routeStore.selectedRouteId && !showUberSearch }">
<!-- Floating Triggers -->
<div v-if="!showUberSearch" class="triggers-row">
<!-- Shrunk Trigger (Icon only) -->
<div
v-if="routeStore.selectedRouteId"
class="uber-search-trigger circular"
@click="openUberSearch"
title="Buscar"
>
<span class="material-icons">search</span>
</div>
<!-- Normal Trigger -->
<div
v-else
class="uber-search-trigger"
@click="openUberSearch"
>
<span class="material-icons search-icon">search</span>
<span class="trigger-text">¿A dónde vamos?</span>
</div>
</div>
<!-- Google Maps Style Navigation Summary Card -->
<Transition name="uber-slide">
<div v-if="navigationInfo" class="navigation-summary-card">
<div class="nav-card-accent"></div>
<div class="nav-content">
<div class="nav-left">
<div class="nav-stats">
<span class="nav-time">{{ navigationInfo.duration }}</span>
<span class="nav-dist">{{ navigationInfo.distance }}</span>
</div>
<div class="nav-destination">Parada: {{ navigationInfo.targetName }}</div>
</div>
<div class="nav-actions">
<button class="nav-btn-close" @click="clearNavigation">
<span class="material-icons">close</span>
</button>
</div>
</div>
</div>
</Transition>
<!-- Uber-style Search Panel -->
<Transition name="uber-slide">
<div v-if="showUberSearch" class="uber-search-panel" :class="{ 'is-focused': isInputFocused }">
<div class="uber-search-header">
<button class="back-btn" @click="closeUberSearch">
<span class="material-icons">arrow_back</span>
</button>
<div class="search-title">Planear viaje</div>
</div>
<div class="search-inputs-wrapper">
<div class="location-line">
<div class="dot-origin"></div>
<div class="line"></div>
<div class="dot-dest"></div>
</div>
<div class="inputs-column">
<div class="input-group">
<input
v-model="originQuery"
type="text"
placeholder="Mi ubicación"
class="uber-input"
readonly
>
</div>
<div class="input-group">
<input
v-model="destinationQuery"
type="text"
placeholder="¿A dónde vamos?"
class="uber-input focusable"
autofocus
@focus="onInputFocus"
@blur="onInputBlur"
>
<button v-if="destinationQuery" @click="destinationQuery = ''" class="clear-btn">
<span class="material-icons">close</span>
</button>
</div>
</div>
</div>
<div class="search-options">
<label class="route-toggle">
<input type="checkbox" v-model="showRoutesToggle">
<span>Ver Rutas</span>
</label>
<button
v-if="routeStore.selectedRouteId || markers.length > 0"
class="clear-map-btn"
@click="clearAllMapData"
>
<span class="material-icons">layers_clear</span>
Limpiar Mapa
</button>
</div>
<!-- Results -->
<div class="uber-results">
<!-- Bus Stop Results -->
<template v-if="!showRoutesToggle">
<div
v-for="stop in filteredSearchResults"
:key="stop.id"
class="uber-result-item"
@click="selectStopFromSearch(stop)"
>
<div class="result-icon">
<span class="material-icons">directions_bus</span>
</div>
<div class="result-content">
<div class="result-name">{{ stop.name }}</div>
<div class="result-address">Parada de Autobús</div>
</div>
<span class="material-icons check-icon">chevron_right</span>
</div>
</template>
<!-- Routes List -->
<template v-else>
<div
v-for="route in routeStore.allRoutes"
:key="route.id"
class="uber-result-item"
:class="{ 'selected-route': route.id === routeStore.selectedRouteId }"
@click="selectRouteAndClose(route.id, route.name)"
>
<div class="result-icon">
<span class="material-icons">route</span>
</div>
<div class="result-info">
<div class="result-name">{{ route.name }}</div>
<div class="result-address">Ruta de transporte público</div>
</div>
<span v-if="route.id === routeStore.selectedRouteId" class="material-icons check-icon">check_circle</span>
</div>
</template>
</div>
</div>
</Transition>
</div>
<!-- Offers Bottom Sheet -->
<Transition name="sheet-slide">
<div v-if="showPromos && couponStore.coupons.length > 0" class="offers-sheet">
<!-- Handle -->
<div class="sheet-handle"></div>
<!-- Cabecera -->
<div class="sheet-header">
<div class="sheet-title-group">
<span class="material-icons">local_offer</span>
<strong>Ofertas Disponibles</strong>
<span class="sheet-count">({{ couponStore.coupons.length }})</span>
</div>
<button class="close-btn" @click="showPromos = false">
<span class="material-icons">close</span>
</button>
</div>
<!-- Card area with nav arrows -->
<div class="sheet-card-area">
<!-- Arrow prev -->
<button class="sheet-nav sheet-nav--prev" @click="prevPromo" :disabled="couponStore.coupons.length < 2">
<span class="material-icons">chevron_left</span>
</button>
<!-- Promo Card -->
<Transition name="carousel-slide" mode="out-in">
<div
v-if="currentPromo"
:key="currentPromo.id"
class="sheet-card"
@mouseenter="stopCarousel"
@touchstart="stopCarousel"
@mouseleave="startCarousel"
>
<!-- Image -->
<div class="sheet-img-wrap">
<img
:src="getImageUrl(currentPromo.image_url, 'coupon')"
class="sheet-img"
:alt="currentPromo.title"
@error="(e) => (e.target as HTMLImageElement).src = getImageUrl(null, 'coupon')"
/>
<span v-if="currentPromo.discount_percentage" class="sheet-discount">
-{{ currentPromo.discount_percentage }}%
</span>
</div>
<!-- Info -->
<div class="sheet-info">
<p class="sheet-biz-name">{{ currentPromo.business_name || 'SIBU' }}</p>
<h3 class="sheet-promo-title">{{ currentPromo.title }}</h3>
<p class="sheet-promo-desc">{{ currentPromo.description }}</p>
<button class="sheet-cta" @click="router.push('/business/' + currentPromo.business_id)">
Ver Negocio
<span class="material-icons" style="font-size:1rem">arrow_forward</span>
</button>
</div>
</div>
</Transition>
<!-- Arrow next -->
<button class="sheet-nav sheet-nav--next" @click="nextPromo" :disabled="couponStore.coupons.length < 2">
<span class="material-icons">chevron_right</span>
</button>
</div>
<!-- Dots -->
<div class="sheet-dots">
<button
v-for="(_, i) in couponStore.coupons"
:key="i"
class="sheet-dot"
:class="{ 'sheet-dot--active': i === currentCarouselIndex }"
@click="currentCarouselIndex = i; startCarousel()"
></button>
</div>
</div>
</Transition>
</div>
</div>
<!-- Modal for details -->
<BusStopInfoModal
:is-open="showBusStopModal"
:bus-stop="selectedBusStop"
@close="closeBusStopModal"
@navigate="drawInternalWalkingRoute"
/>
<Transition name="modal-fade">
<div v-if="showPromoModal && selectedPromo" class="promo-modal-overlay" @click="closePromoModal">
<div class="promo-modal-content" @click.stop>
<div class="promo-header-modal">
<img
:src="getImageUrl(selectedPromo.image_url, 'coupon')"
class="promo-img-modal"
@error="(e) => (e.target as HTMLImageElement).src = getImageUrl(null, 'coupon')"
/>
<div class="promo-badge-modal">PROMO</div>
</div>
<div class="promo-body-modal">
<h2 class="promo-title-modal">{{ selectedPromo.title }}</h2>
<div class="promo-biz">{{ selectedPromo.business_name }}</div>
<p>{{ selectedPromo.description }}</p>
</div>
<div class="promo-actions-modal">
<button class="business-detail-btn-modal" @click="router.push('/business/' + selectedPromo.business_id)">Ver Negocio</button>
<button class="promo-claim-btn" @click="claimPromo">Reclamar Cupón</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.split-view {
display: flex;
width: 100%;
height: calc(100vh - 64px); /* Adjust based on header height */
overflow: hidden;
position: relative;
}
.map-side {
width: 100%;
height: 100%;
position: relative;
}
.map-view {
width: 100%;
height: 100%;
position: relative;
}
.map-container {
width: 100%;
height: 100%;
}
.map {
width: 100%;
height: 100%;
}
/* ═══════════════════════════════════════
BOTÓN DE OFERTAS (MAPA)
Mantenido simple y funcional
No premiun - solo funcional
═══════════════════════════════════════ */
.offers-fab {
width: 56px;
height: 56px;
border-radius: 50%;
background: #fee715;
color: #000;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
position: relative;
z-index: 1001;
}
.offers-fab.active {
background: #f44336;
color: #fff;
}
.offers-badge {
position: absolute;
top: -5px;
right: -5px;
background: #f44336;
color: white;
font-size: 12px;
font-weight: bold;
padding: 2px 6px;
border-radius: 10px;
border: 2px solid #fff;
}
/* ═══════════════════════════════════════
OFFERS BOTTOM SHEET
═══════════════════════════════════════ */
.offers-sheet {
position: fixed;
bottom: 110px; /* Separado más de la barra inferior para evitar solapamiento */
left: 10px;
right: 10px;
background: #fff;
border: 2px solid #000;
border-radius: 12px;
z-index: 2000;
padding-bottom: 10px;
box-shadow: 0 -4px 15px rgba(0,0,0,0.2);
color: #000;
}
@media (prefers-color-scheme: dark) {
.offers-sheet {
background: #111;
color: #fff;
border-color: #333;
}
}
.sheet-handle {
width: 40px;
height: 4px;
border-radius: 99px;
background: var(--border-color);
margin: 10px auto 8px;
}
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.sheet-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.sheet-star { color: var(--active-color); font-size: 1.125rem; }
.sheet-title {
font-size: 1rem;
font-weight: 800;
color: var(--text-primary);
}
.sheet-count-badge {
background: var(--active-color);
color: #101820;
font-size: 0.6875rem;
font-weight: 800;
padding: 0.15rem 0.5rem;
border-radius: 99px;
}
.sheet-close {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-secondary);
transition: color 0.2s;
}
.sheet-close:hover { color: var(--text-primary); }
.sheet-close .material-icons { font-size: 1.125rem; }
/* Card area */
.sheet-card-area {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.75rem 0.5rem 0.375rem;
min-height: 110px;
}
.sheet-nav {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.18s;
}
.sheet-nav:disabled { opacity: 0.3; cursor: default; }
.sheet-nav:not(:disabled):hover { background: var(--active-color); color: #101820; border-color: var(--active-color); }
.sheet-nav .material-icons { font-size: 1.25rem; }
.sheet-card {
flex: 1;
display: flex;
gap: 0.75rem;
align-items: flex-start;
min-width: 0;
}
.sheet-img-wrap {
position: relative;
flex-shrink: 0;
width: 88px;
height: 88px;
border-radius: 12px;
overflow: hidden;
background: var(--bg-primary);
}
.sheet-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.sheet-discount {
position: absolute;
top: 6px;
left: 6px;
background: #ef4444;
color: #ffffff;
font-size: 0.6875rem;
font-weight: 800;
padding: 0.15rem 0.4rem;
border-radius: 6px;
line-height: 1.2;
}
.sheet-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.sheet-biz-name {
margin: 0;
font-size: 0.6875rem;
font-weight: 700;
color: var(--active-color);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sheet-promo-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 800;
color: var(--text-primary);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sheet-promo-desc {
margin: 0;
font-size: 0.8rem;
color: var(--text-secondary);
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
.sheet-cta {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.375rem;
padding: 0.4rem 0.875rem;
background: var(--active-color);
color: #101820;
border: none;
border-radius: 99px;
font-size: 0.8125rem;
font-weight: 800;
font-family: inherit;
cursor: pointer;
transition: transform 0.15s;
align-self: flex-start;
}
.sheet-cta:active { transform: scale(0.97); }
/* Dots */
.sheet-dots {
display: flex;
justify-content: center;
gap: 6px;
padding: 0.25rem 0 0.25rem;
}
.sheet-dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
background: var(--border-color);
cursor: pointer;
transition: all 0.25s;
padding: 0;
}
.sheet-dot--active {
width: 20px;
border-radius: 4px;
background: var(--active-color);
}
/* Carousel Slide Animation */
.carousel-slide-enter-active,
.carousel-slide-leave-active {
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.carousel-slide-enter-from { opacity: 0; transform: translateX(32px); }
.carousel-slide-leave-to { opacity: 0; transform: translateX(-32px); }
/* Uber-like Search Interface Styles */
.uber-search-container {
position: fixed;
top: 90px;
left: 16px;
right: 16px;
z-index: 1100;
pointer-events: none;
}
.uber-search-container > * {
pointer-events: auto; /* Re-enable for children */
}
.uber-search-trigger {
background: var(--header-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
height: 56px;
border-radius: 18px;
display: flex;
align-items: center;
padding: 0 20px;
box-shadow: var(--shadow);
cursor: pointer;
border: 1px solid var(--border-color);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
width: 100%; /* Take full width of container */
max-width: 500px;
}
.uber-search-trigger.circular {
width: 60px;
padding: 0;
justify-content: center;
border-radius: 20px;
}
.triggers-row {
display: flex;
gap: 12px;
align-items: center;
}
.schedules-btn-floating {
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
color: #101820;
border: none;
height: 60px;
padding: 0 24px;
border-radius: 20px;
display: flex;
align-items: center;
gap: 10px;
font-weight: 800;
box-shadow: 0 10px 20px rgba(254, 231, 21, 0.2);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
}
.schedules-btn-floating:hover {
transform: translateY(-4px);
box-shadow: 0 15px 30px rgba(254, 231, 21, 0.3);
}
.uber-search-trigger:hover {
transform: translateY(-4px);
background: var(--hover-bg);
}
.search-icon {
color: var(--active-color);
margin-right: 12px;
}
.trigger-text {
color: var(--text-primary);
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.uber-search-panel {
position: fixed;
top: 70px; /* Debajo del header superior */
left: 0;
right: 0;
background: var(--header-bg);
backdrop-filter: blur(30px);
-webkit-backdrop-filter: blur(30px);
border-radius: 24px;
box-shadow: 0 40px 100px rgba(0,0,0,0.6);
padding: 16px;
z-index: 2500;
border: 1px solid var(--border-color);
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
max-width: 500px;
margin: 0 auto;
max-height: calc(100vh - 140px); /* Nunca sobrepasa la pantalla */
overflow-y: auto;
}
/* Fix para que no se oculte al salir el teclado */
.uber-search-panel.is-focused {
top: 60px;
box-shadow: 0 10px 40px rgba(0,0,0,0.4);
}
.uber-search-header {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.back-btn {
background: var(--hover-bg);
border: none;
cursor: pointer;
color: var(--text-primary);
width: 40px;
height: 40px;
border-radius: 12px;
margin-right: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.search-title {
font-size: 1.4rem;
font-weight: 800;
color: var(--text-primary);
letter-spacing: -0.02em;
}
.search-inputs-wrapper {
display: flex;
gap: 16px;
background: var(--bg-secondary);
padding: 14px;
border-radius: 16px;
margin-bottom: 12px;
border: 1px solid var(--border-color);
}
.location-line {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 15px;
}
.dot-origin {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--active-color);
box-shadow: 0 0 10px var(--active-color);
}
.line {
width: 2px;
height: 45px;
background: linear-gradient(to bottom, var(--active-color), var(--border-color));
margin: 4px 0;
}
.dot-dest {
width: 10px;
height: 10px;
border-radius: 2px;
background: #fff;
}
.inputs-column {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
.input-group {
position: relative;
display: flex;
align-items: center;
}
.uber-input {
width: 100%;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
padding: 12px 16px;
border-radius: 12px;
font-size: 1rem;
color: var(--text-primary);
}
.uber-input:focus {
background: rgba(255, 255, 255, 0.07);
border-color: var(--active-color);
box-shadow: 0 0 0 4px rgba(254, 231, 21, 0.1);
}
.search-options {
padding: 12px 0;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.clear-map-btn {
display: flex;
align-items: center;
gap: 8px;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.2);
padding: 8px 16px;
border-radius: 12px;
font-size: 0.9rem;
font-weight: 700;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.clear-map-btn:hover {
background: rgba(239, 68, 68, 0.2);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
}
.clear-map-btn:active {
transform: scale(0.95);
}
.clear-map-btn .material-icons {
font-size: 20px;
}
.route-toggle {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
font-weight: 700;
color: var(--text-primary);
font-size: 1rem;
}
.route-toggle input {
width: 20px;
height: 20px;
cursor: pointer;
accent-color: var(--active-color);
}
.uber-results {
margin-top: 12px;
max-height: 55vh; /* Ajustado para dar espacio a la barra inferior */
overflow-y: auto;
padding-bottom: 120px; /* Suficiente espacio para que no lo tape la barra de navegación */
}
.uber-result-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
cursor: pointer;
border-radius: 16px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin-bottom: 4px;
}
.uber-result-item:hover {
background: var(--hover-bg);
transform: translateX(8px);
}
.selected-route {
background: var(--active-bg);
border: 1px solid var(--active-color);
}
.check-icon {
color: var(--active-color);
margin-left: auto;
}
.result-icon {
width: 44px;
height: 44px;
background: var(--bg-secondary);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
color: var(--active-color);
border: 1px solid var(--border-color);
}
.result-name {
font-weight: 700;
color: var(--text-primary);
font-size: 1.1rem;
}
.result-address {
font-size: 0.9rem;
color: var(--text-secondary);
}
/* Uber Slide Animation */
.uber-slide-enter-active,
.uber-slide-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.uber-slide-enter-from,
.uber-slide-leave-to {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
/* Reposicion de elementos fijos */
.map-floating-controls {
position: fixed;
bottom: 85px;
right: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
z-index: 1100;
}
.promos-badge-wrapper {
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
}
.promos-badge-wrapper:hover {
transform: scale(1.1);
}
.close-promos-icon {
width: 50px;
height: 50px;
border-radius: 50%;
border: none;
background: #ef4444;
color: white;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4);
cursor: pointer;
}
.location-loader-btn {
background: var(--header-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
border-radius: 50%;
color: var(--active-color);
box-shadow: var(--shadow);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1001;
}
.location-loader-btn:hover {
transform: scale(1.1);
background: var(--hover-bg);
}
.location-loader-btn .material-icons {
font-size: 26px;
}
.promos-toggle-btn {
width: 60px;
height: 60px;
border-radius: 20px;
background: var(--active-color);
color: #101820;
border: none;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 25px rgba(254, 231, 21, 0.4);
cursor: pointer;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.promos-toggle-btn.active {
background: var(--text-primary);
color: var(--bg-primary);
}
.promos-toggle-btn .material-icons {
font-size: 28px;
}
.notification-dot {
position: absolute;
top: -4px;
right: -4px;
width: 14px;
height: 14px;
background: #f44336;
border-radius: 50%;
border: 2px solid var(--bg-primary);
animation: pulse-dot 2s infinite;
}
@keyframes pulse-dot {
0% { transform: scale(1); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
.pulse {
animation: pulse-animation 2s infinite;
}
@keyframes pulse-animation {
0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(254, 231, 21, 0.7); }
70% { transform: scale(1.1); box-shadow: 0 0 0 15px rgba(254, 231, 21, 0); }
100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(254, 231, 21, 0); }
}
/* Bottom sheet transition */
.sheet-slide-enter-active,
.sheet-slide-leave-active {
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s;
}
.sheet-slide-enter-from,
.sheet-slide-leave-to {
transform: translateY(100%);
opacity: 0;
}
.location-button .material-icons { font-size: 24px; }
/* Responsive */
@media (max-width: 900px) {
.uber-search-container { top: 80px; }
.map-floating-controls {
bottom: 130px;
right: 14px;
}
.offers-sheet {
bottom: 60px;
}
}
/* Modal Simple Styles (already mostly covered) */
.promo-modal-overlay {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.8);
z-index: 3000;
display: flex; align-items: center; justify-content: center;
}
.promo-modal-content {
background: var(--card-bg); width: 90%; max-width: 450px;
border-radius: 20px; overflow: hidden;
}
.promo-header-modal { position: relative; height: 200px; }
.promo-img-modal { width: 100%; height: 100%; object-fit: cover; }
.promo-badge-modal { position: absolute; bottom: 0; left: 0; background: #FF4081; color: white; padding: 5px 15px; font-weight: 800; }
.promo-body-modal { padding: 25px; }
.promo-title-modal { font-size: 1.5rem; font-weight: 800; margin-bottom: 10px; }
.promo-biz { color: var(--active-color); font-weight: 700; margin-bottom: 15px; }
.promo-actions-modal { padding: 0 25px 25px; display: flex; gap: 10px; }
.promo-claim-btn { flex: 1; background: #FF4081; color: white; border: none; padding: 15px; border-radius: 10px; font-weight: 700; cursor: pointer; }
.business-detail-btn-modal { flex: 1; background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-color); padding: 15px; border-radius: 10px; font-weight: 700; cursor: pointer; }
.close-modal-btn {
position: absolute;
top: 15px;
right: 15px;
background: rgba(0,0,0,0.5);
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
}
.close-modal-btn:hover {
background: rgba(0,0,0,0.8);
}
.tourist-badge {
background: #4CAF50 !important;
}
.business-category-chip {
display: inline-block;
padding: 4px 12px;
background: var(--bg-secondary);
border-radius: 100px;
font-size: 0.75rem;
font-weight: 700;
color: var(--active-color);
margin-bottom: 15px;
}
.business-detail-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
color: var(--text-secondary);
}
.business-detail-item .material-icons {
font-size: 1.2rem;
color: var(--active-color);
}
.call-btn {
background: #1976D2 !important;
text-decoration: none;
}
/* Google Maps Style Navigation Card */
.navigation-summary-card {
position: absolute;
bottom: 0px;
left: 0;
right: 0;
background: white;
margin: 12px;
padding: 16px;
border-radius: 12px;
box-shadow: 0 4px 25px rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
z-index: 1000;
overflow: hidden;
border: 1px solid rgba(0,0,0,0.05);
}
.nav-card-accent {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: #4285F4;
}
.nav-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-left {
flex: 1;
}
.nav-stats {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 2px;
}
.nav-time {
font-size: 1.4rem;
font-weight: 700;
color: #1a73e8;
}
.nav-dist {
font-size: 1rem;
color: #5f6368;
font-weight: 500;
}
.nav-destination {
font-size: 0.9rem;
color: #202124;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 250px;
}
.nav-btn-close {
background: #f1f3f4;
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #5f6368;
transition: all 0.2s;
}
.nav-btn-close:hover {
background: #e8eaed;
color: #202124;
}
@media (prefers-color-scheme: dark) {
.navigation-summary-card {
background: #202124;
border-color: rgba(255,255,255,0.1);
}
.nav-time { color: #8ab4f8; }
.nav-dist { color: #bdc1c6; }
.nav-destination { color: #e8eaed; }
.nav-btn-close { background: #3c4043; color: #bdc1c6; }
}
.sheet-fav-pos {
position: absolute;
top: 6px;
right: 6px;
z-index: 10;
}
.promo-modal-fav {
position: absolute;
top: 15px;
left: 15px;
z-index: 10;
}
</style>