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

2086 lines
58 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, defineAsyncComponent } 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 { analyticsService } from "@/services/analyticsService";
import { getImageUrl } from "@/utils/imageUrl";
import { useDirectionsRoute } from "@/composables/useDirectionsRoute";
import { useParadaCercana } from "@/composables/useParadaCercana";
import { useETA } from "@/composables/useETA";
const BusStopInfoModal = defineAsyncComponent(() => import("@/components/BusStopInfoModal.vue"));
const ETACard = defineAsyncComponent(() => import("@/components/ETACard.vue"));
import type { BusStop } from '@/types'
import { useFlujoPrincipal } from "@/composables/useFlujoPrincipal";
import { useMapState } from "@/composables/useMapState";
const router = useRouter();
const { t } = useI18n();
const routeStore = useRouteStore();
const mapStore = useMapStore();
const busStopStore = useBusStopStore();
const couponStore = useCouponStore();
const { map, isLoaded, error: mapsError, initMap, addCleanMarker, addHtmlMarker, setCenter, setZoom, addMarker, clearAllOverlays } = useGoogleMaps();
const { estasCargando: estasCargandoRuta, errorRuta } = useDirectionsRoute();
const { encontrarParadaCercana, limpiarCaminata, paradaCercana, distanciaMetros, duracionCaminata } = useParadaCercana();
const { calcularETA, busesActivos, cargando: etaCargando } = useETA();
const { procesarSeleccionDeRuta } = useFlujoPrincipal();
const { limpiarMapa: limpiarTodoCentralizado } = useMapState();
const showETACard = ref(false);
// Local old tracking states can be removed, but kept for compatibility or Uber flow:
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 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
const alturaNavbar = ref(64);
// 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 = "";
// 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;
}
limpiarCaminata();
showETACard.value = false;
// Nueva Purgación Centralizada:
limpiarTodoCentralizado();
// 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 () => {
const navbar = document.querySelector('#navbar-admin') ?? document.querySelector('nav') ?? document.querySelector('header');
if (navbar) {
alturaNavbar.value = navbar.getBoundingClientRect().height;
} else {
alturaNavbar.value = 64;
}
analyticsService.logEvent({ event_name: 'screen_view', properties: { screen_name: 'Map' } })
// Add click outside listener
document.addEventListener('click', handleClickOutside);
// Load routes, bus stops and promos in parallel
await Promise.all([
routeStore.loadRoutes(),
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();
}
// 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')
// FLOW REFINEMENT: Find the optimal entrance stop first
if (newStops.length > 0) {
await highlightOptimalStopForRoute();
}
await updateMapMarkers();
}
}
}
},
{ deep: true }
);
// Replaced by useMapState central clearing
function clearMapMarkers() {
limpiarTodoCentralizado()
}
async function updateMapMarkers() {
if (!isLoaded.value || !map.value) return;
const currentRequestRouteId = routeStore.selectedRouteId;
const stops = [...routeStore.selectedRouteStops];
if (!currentRequestRouteId || stops.length === 0) {
clearMapMarkers();
return;
}
const selectedRouteObj = routeStore.allRoutes.find(r => r.id === currentRequestRouteId) || { id: currentRequestRouteId, short_name: currentRequestRouteId };
// Llamar al procesador de flujo principal, lo cual limpia el mapa y centra.
await procesarSeleccionDeRuta(selectedRouteObj, stops as BusStop[], map.value);
// Agregar todos los stops como marcadores 'normales' para que se vean en el mapa
const { paradaCercana } = useParadaCercana();
stops.forEach(stop => {
// Evitar sobre dibujar si es la cercana (useFlujoPrincipal ya se encargó)
if (paradaCercana.value && stop.id === paradaCercana.value.id) return;
addCleanMarker(
{ lat: stop.latitude, lng: stop.longitude },
stop.name,
'normal',
() => handleBusStopClick(stop)
);
});
}
function updateMarkersStyles() {
// Empty space: Clean markers are static and distinct per requirement.
}
// 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
}
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(): Promise<void> {
return new Promise((resolve) => {
if (!navigator.geolocation) {
resolve();
return;
}
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 }
);
resolve();
},
(error) => {
console.warn("SIBU | Geolocalización denegada:", error.message);
resolve();
},
{
enableHighAccuracy: true,
timeout: 8000,
maximumAge: 30000
}
);
});
}
/**
* CÁLCULO DE INTELIGENCIA VIAL:
* Encuentra la parada más cercana dentro de la ruta seleccionada
* y la resalta para el usuario.
*/
async function highlightOptimalStopForRoute() {
if (!userCoords.value) {
await locateUser();
}
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 mediante calles...');
// Encontrar parada real y añadir ruta peatonal azul punteada
await encontrarParadaCercana(userCoords.value, routeStore.selectedRouteStops as BusStop[], map.value || undefined);
if (paradaCercana.value) {
const stopObj = paradaCercana.value as BusStop;
console.log(`🤖 JARVIS: Parada óptima detectada: ${stopObj.name}`);
// Ya no centramos o hacemos zoom aquí manual porque la nueva gráfica de updateMapMarkers ajusta bounds y engloba location.
// Añadir el PULSO NARANJA
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 }
);
// Calcular ETAs
await calcularETA(routeStore.selectedRouteId!, stopObj);
showETACard.value = true;
}
}
/**
* 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) (Retirado a favor de ETA Card / Parada Cercana Banner)
if (leg) {
// console.log('Distancia', leg.distance?.text);
}
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 });
}
}
});
}
</script>
<template>
<div class="split-view">
<!-- Main Map Container -->
<div class="map-side">
<div class="map-view">
<!-- Status overlay para SIBU Directions API -->
<div v-if="estasCargandoRuta || errorRuta" class="status-indicator">
<div v-if="estasCargandoRuta" class="loading-pill">
Calculando ruta real...
</div>
<div v-if="errorRuta" class="error-pill">
{{ errorRuta }}
</div>
</div>
<!-- Banner de Parada Más Cercana Inteligente -->
<div
v-if="paradaCercana && routeStore.selectedRouteId"
class="fixed left-0 right-0 z-40 px-3 transition-transform duration-300 pointer-events-none"
:style="{ top: alturaNavbar + 'px' }"
>
<div class="bg-white dark:bg-gray-900 rounded-b-2xl shadow-xl border-t-4 border-blue-600 border-t-blue-600 p-3 flex items-center gap-3 pointer-events-auto">
<div class="bg-blue-100 dark:bg-blue-900/40 rounded-full p-2 shrink-0">
<span class="material-icons text-blue-600 dark:text-blue-400">directions_bus</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-[11px] text-gray-500 font-bold uppercase">Parada más cercana</p>
<p class="text-sm font-bold text-gray-800 dark:text-white truncate">
{{ paradaCercana?.name }}
</p>
<p class="text-xs text-blue-600 dark:text-blue-400 font-medium whitespace-nowrap overflow-hidden text-ellipsis">
{{ (distanciaMetros && distanciaMetros < 1000) ? Math.round(distanciaMetros) + ' m' : (distanciaMetros ? (distanciaMetros / 1000).toFixed(1) + ' km' : '') }}
<span v-if="duracionCaminata">· {{ Math.round(duracionCaminata / 60) }} min caminando</span>
</p>
</div>
<button @click="paradaCercana = null" class="text-gray-400 hover:text-gray-600 shrink-0 p-1">
<span class="material-icons">close</span>
</button>
</div>
</div>
<div class="map-container">
<!-- Floating Offers Button at exact location -->
<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>
<!-- 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>
<ETACard
:is-open="showETACard"
:stop-name="paradaCercana?.name || ''"
:walk-distance="distanciaMetros"
:walk-duration="duracionCaminata"
:buses="busesActivos"
:is-loading="etaCargando"
@close="showETACard = false"
@refresh="paradaCercana && routeStore.selectedRouteId ? calcularETA(routeStore.selectedRouteId, paradaCercana) : null"
/>
</div>
</template>
<style scoped>
.split-view {
display: flex;
width: 100%;
height: calc(100vh - 64px); /* Adjust based on header height */
overflow: hidden;
position: relative;
}
/* SIBU Directions API status tags */
.status-indicator {
position: absolute;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.loading-pill {
background-color: #1e40af; /* Tailwind blue-800 */
color: white;
padding: 0.5rem 1rem;
border-radius: 9999px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
border: 2px solid white;
font-size: 0.875rem;
font-weight: 500;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.error-pill {
background-color: #dc2626; /* Tailwind red-600 */
color: white;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
font-size: 0.875rem;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .5;
}
}
.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>