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

1991 lines
56 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 } = useGoogleMaps();
const { estasCargando: estasCargandoRuta, errorRuta } = useDirectionsRoute();
const { encontrarParadaCercana, 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;
showRoutesToggle.value = true; // Forzar que al abrir estemos en modo rutas
}
function closeUberSearch() {
showUberSearch.value = false;
destinationQuery.value = "";
}
async function clearAllMapData() {
console.log('🤖 JARVIS: Iniciando PURGA nuclear centralizada...');
// 1. UI inmediata
showUberSearch.value = false;
showRoutesToggle.value = false;
destinationQuery.value = "";
stopSearchQuery.value = "";
showETACard.value = false;
paradaCercana.value = null; // Borrar banner superior
// 2. Invalidar hilos en curso
mappingSequenceId.value++;
try {
// 3. Resetear stores
routeStore.clearSelection();
lastProcessedRouteId.value = null;
// 4. LIMPIEZA CENTRALIZADA (useMapState)
// Esto limpia markers, renderers, polylines, overlays HTML, etc.
limpiarTodoCentralizado();
// 5. Limpiar referencias locales del componente (aunque no tengan mapa asignado ya)
markers.value = [];
promoMarkers.value = [];
unitMarkers.value.clear();
polyline.value = null;
walkingPolyline.value = null;
walkingPolylineBorder.value = null;
optimalStopPulse.value = null;
// 6. Restaurar SOLO el marcador del usuario si tenemos ubicación
await nextTick();
if (userCoords.value) {
const { lat, lng } = userCoords.value;
userMarker.value = addHtmlMarker(
{ lat, lng },
sonarHtml,
{ x: -30, y: -30 }
);
}
console.log('🤖 JARVIS: Purga completada ✓');
} catch (err) {
console.error('❌ JARVIS: Error en purga:', 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();
});
map.value.addListener('click', () => {
if (showETACard.value) {
showETACard.value = false;
}
});
}
// 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 || isUpdatingMarkers.value) return;
isUpdatingMarkers.value = true;
const currentRequestRouteId = routeStore.selectedRouteId;
const stops = [...routeStore.selectedRouteStops];
try {
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);
// ⛔ ABORTAR SI EL USUARIO LIMPIÓ EL MAPA MIENTRAS DIBUJÁBAMOS
if (routeStore.selectedRouteId !== currentRequestRouteId) {
console.log('Abortando dibujado de paradas (la ruta fue limpiada o cambiada)');
return;
}
// Agregar todos los stops como marcadores 'normales' para que se vean en el mapa
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)
);
});
} finally {
isUpdatingMarkers.value = false;
}
}
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 }
);
// PASO 1: Mostrar ETACard inferior primero
await calcularETA(routeStore.selectedRouteId!, stopObj);
showETACard.value = true;
// PASO 2: Esperar 2 segundos antes de mostrar el banner superior
// para que no saturen la pantalla al mismo tiempo
await new Promise(resolve => setTimeout(resolve, 2000));
// PASO 3: Mostrar banner superior solo si ETACard sigue abierto
// (si el usuario ya cerró el ETACard, no mostrar el banner)
// paradaCercana ya tiene el valor, el banner aparece automáticamente
// porque usa v-if="paradaCercana && routeStore.selectedRouteId && !showETACard"
}
}
/**
* 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);
}
}
async 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
try {
const { RoutesService } = await google.maps.importLibrary("routes") as any;
const routeService = new RoutesService();
const response = await routeService.computeRoutes({
origin: {
location: {
latLng: { lat: origin.lat, lng: origin.lng }
}
},
destination: {
location: {
latLng: { lat: targetStop.latitude, lng: targetStop.longitude }
}
},
travelMode: 'DRIVE',
routingPreference: 'TRAFFIC_UNAWARE',
polylineQuality: 'HIGH_QUALITY',
polylineEncoding: 'ENCODED_POLYLINE',
});
if (response.routes && response.routes.length > 0) {
const route = response.routes[0];
if (route.polyline && route.polyline.encodedPolyline) {
const path = google.maps.geometry.encoding.decodePath(route.polyline.encodedPolyline);
if (walkingPolyline.value) walkingPolyline.value.setMap(null);
if (walkingPolylineBorder.value) walkingPolylineBorder.value.setMap(null);
const { registrarPolyline: regPoly } = useMapState();
// CAPA 1: Borde blanco (Para dar contraste estilo Google Maps)
walkingPolylineBorder.value = new google.maps.Polyline({
path: path,
geodesic: true,
strokeColor: '#FFFFFF',
strokeOpacity: 0.9,
strokeWeight: 10,
map: map.value,
zIndex: 5
});
regPoly(walkingPolylineBorder.value);
// CAPA 2: Línea Indigo Central (La ruta principal)
walkingPolyline.value = new google.maps.Polyline({
path: path,
geodesic: true,
strokeColor: '#4285F4', // Azul Google Maps
strokeOpacity: 1.0,
strokeWeight: 5,
map: map.value,
zIndex: 10
});
regPoly(walkingPolyline.value);
// Ajustar zoom para mostrar toda la ruta de caminata
if (map.value) {
const bounds = new google.maps.LatLngBounds();
path.forEach(p => bounds.extend(p));
map.value.fitBounds(bounds, { top: 100, bottom: 200, left: 50, right: 50 });
}
}
}
} catch (error) {
console.warn('SIBU | Error trazando ruta a pie con Routes API:', error);
}
}
</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 (Movido a triggers-row para alineación) -->
<!-- Comentado fuera de aquí, lo pondremos abajo -->
<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">directions_bus</span>
</div>
<!-- Nuevo Banner de Parada Cercana Alineado -->
<div
v-if="paradaCercana && routeStore.selectedRouteId && !showETACard"
class="uber-search-trigger best-stop-banner"
>
<span class="material-icons text-yellow-500 mr-3">directions_bus</span>
<div class="flex flex-col flex-1 truncate">
<span class="text-[10px] uppercase font-bold text-yellow-500 leading-tight">Parada cercana</span>
<span class="trigger-text truncate leading-tight">{{ paradaCercana?.name }}</span>
</div>
<div class="text-[11px] font-bold bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded-lg ml-2 whitespace-nowrap">
{{ (distanciaMetros && distanciaMetros < 1000) ? Math.round(distanciaMetros) + 'm' : (distanciaMetros ? (distanciaMetros / 1000).toFixed(1) + 'km' : '') }}
</div>
<button @click.stop="clearAllMapData" class="ml-3 p-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<span class="material-icons text-[20px] text-gray-400 hover:text-red-500">close</span>
</button>
</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">Rutas Disponibles</div>
</div>
<!-- Inputs and Toggle removed per request -->
<div class="search-actions-header">
<button
v-if="routeStore.selectedRouteId || markers.length > 0"
class="clear-map-btn"
@click="clearAllMapData"
>
<span class="material-icons text-sm">layers_clear</span>
Limpiar Mapa
</button>
</div>
<!-- Results -->
<div class="uber-results custom-scrollbar">
<!-- Listado simplificado de rutas -->
<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">directions_bus</span>
</div>
<div class="result-content">
<div class="result-name">{{ route.name }}</div>
<div class="result-address">Ruta de Autobús</div>
</div>
<span class="material-icons check-icon">
{{ route.id === routeStore.selectedRouteId ? 'check_circle' : 'chevron_right' }}
</span>
</div>
</div> <!-- Fin uber-results -->
</div> <!-- Fin uber-search-panel -->
</Transition>
</div> <!-- Fin uber-search-container -->
<!-- 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;
/* Base 72px (altura menú) + 60px espacio visual + safe area */
bottom: calc(72px + 60px + env(safe-area-inset-bottom, 0px));
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;
/* Limitar altura máxima para no ocupar toda la pantalla */
max-height: calc(100vh - 200px);
overflow-y: auto;
}
@media (prefers-color-scheme: dark) {
.offers-sheet {
background: #111;
color: #fff;
border-color: #333;
}
}
@media (max-width: 900px) {
.offers-sheet {
/* En móvil más espacio aún por el menú nativo */
bottom: calc(135px + env(safe-area-inset-bottom, 0px));
left: 8px;
right: 8px;
}
}
.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;
}
.best-stop-banner {
flex: 1; /* Ocupa el espacio restante al lado de la búsqueda circular */
background: var(--header-bg);
border: 1px solid var(--border-color);
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
max-width: none;
}
.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-actions-header {
display: flex;
justify-content: flex-end;
padding: 8px 0 16px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 8px;
}
.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: 18px;
}
.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;
}
.map-floating-controls {
position: fixed;
/* Subir los botones FAB cuando el carrusel está abierto */
bottom: 85px;
right: 16px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 12px;
}
</style>