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

1922 lines
53 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 { useAuthStore } from "@/stores/auth";
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 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 authStore = useAuthStore();
const { map, isLoaded, error: mapsError, initMap, 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 promoMarkers = ref<any[]>([]);
const userMarker = ref<any>(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);
const optimalStopPulse = ref<any>(null);
const showRouteDropdown = ref(false);
const wasSelectedFromMap = ref(false);
const isInternalSelection = ref(false);
const alturaNavbar = ref(64);
// Search state
const stopSearchQuery = ref("");
const destinationQuery = ref("");
const filteredSearchResults = ref<BusStop[]>([]);
const showSearchDropdown = ref(false);
const showUberSearch = ref(false);
const showRoutesToggle = ref(false);
const showPromos = ref(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;
}
});
// selectStopFromSearch removed as it was unused
function openUberSearch() {
showPromos.value = false; // Cerramos ofertas para evitar solapamiento
showUberSearch.value = true;
showRoutesToggle.value = true; // Forzar que al abrir estemos en modo rutas
}
function closeUberSearch() {
showUberSearch.value = false;
destinationQuery.value = "";
}
// clearAllMapData removed per request
// Modal state removed per request (no more stop markers to click)
function reloadPage() {
window.location.reload();
}
const showPromoModal = ref(false);
const selectedPromo = ref<any>(null);
const isBannerClosing = ref(false);
function animateAndReload() {
isBannerClosing.value = true;
setTimeout(() => {
reloadPage();
}, 450); // Mismo tiempo que la transición
}
function handlePromoClick(promo: any) {
selectedPromo.value = promo;
showPromoModal.value = true;
}
function closePromoModal() {
showPromoModal.value = false;
selectedPromo.value = null;
}
function handleImageError(event: Event) {
const target = event.target as HTMLImageElement;
if (target) {
target.src = getImageUrl(null, 'coupon');
}
}
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' } })
// 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);
}
}
// 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(() => {
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 AND it was from map, show its stops
if (routeStore.selectedRouteId && routeStore.selectedRouteStops.length > 0 && wasSelectedFromMap.value) {
updateMapMarkers();
} else {
// If no route or not from map, ensure it's clean (promos stay though)
clearMapMarkers();
}
// Show promotions on the map
updatePromoMarkers();
// Smart Location: Detect automatically if enabled in profile
if (authStore.userProfile?.auto_location) {
console.log('🤖 JARVIS: Smart Location detectado — localizando automaticamente...');
locateUser();
}
// Apply initial styles based on current zoom
updateMarkersStyles();
}
// Watch for route selection changes
watch(
() => routeStore.selectedRouteId,
async (routeId, oldRouteId) => {
// Si la selección no viene de dentro de MapView (selectRouteAndClose),
// reseteamos el flag de origen Mapa para que el buscador no se "fije"
if (!isInternalSelection.value) {
wasSelectedFromMap.value = false;
}
// 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) {
if (wasSelectedFromMap.value) {
// Only update map visuals if selection came from the Map search flow
await updateMapMarkers(true);
} else {
// If selection came from Schedules or elsewhere, KEEP THE MAP CLEAN
console.log('Selection from outside Map - clearing map markings');
clearMapMarkers();
}
} else {
// Clear markers when no route is selected
lastProcessedRouteId.value = null;
wasSelectedFromMap.value = false; // Reset selection origin
clearMapMarkers();
}
},
{ immediate: false }
);
// Track the last route ID to prevent double updates
const lastProcessedRouteId = ref<string | null>(null);
// Watch for route stops changes - but only if route ID hasn't changed
// This prevents double updates when both watchers fire
watch(
() => routeStore.selectedRouteStops,
async (newStops, oldStops) => {
const currentRouteId = routeStore.selectedRouteId;
// Skip if route ID was just changed (the routeId watcher will handle it)
if (currentRouteId === lastProcessedRouteId.value) {
// Only update if route is selected and map is loaded
// Skip if we're already updating or if stops haven't actually changed
if (currentRouteId && isLoaded.value && !isUpdatingMarkers.value) {
// Check if stops actually changed
if (!oldStops || newStops.length !== oldStops.length ||
newStops.some((stop, idx) => stop.id !== oldStops[idx]?.id)) {
console.log('Route stops changed - updating markers')
await updateMapMarkers(true);
}
}
}
},
{ deep: true }
);
// Replaced by useMapState central clearing
function clearMapMarkers() {
limpiarTodoCentralizado()
}
async function updateMapMarkers(skipZoom = false) {
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.
// Usamos skipZoom para evitar la animación intrusiva de búsqueda cuando no es desde el buscador
await procesarSeleccionDeRuta(selectedRouteObj, stops as BusStop[], map.value, skipZoom);
// ⛔ 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;
}
// All stop markers loop removed per request to avoid marking stops on map
} 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);
}
});
}
async function selectRouteAndClose(routeId: string, routeName: string) {
console.log(`🤖 JARVIS: Iniciando viaje hacia ${routeName}`);
isInternalSelection.value = true;
wasSelectedFromMap.value = true;
await routeStore.selectRoute(routeId, routeName);
showRouteDropdown.value = false;
showUberSearch.value = false; // Close the expanded search panel
// Highlight the optimal stop ONLY in this flow when initiated from the map search
if (routeStore.selectedRouteStops.length > 0) {
await highlightOptimalStopForRoute();
}
isInternalSelection.value = false;
}
async function updateActiveUnits() {
if (!isLoaded.value) return;
try {
if (routeStore.selectedRouteId && paradaCercana.value) {
await calcularETA(routeStore.selectedRouteId, paradaCercana.value as BusStop);
}
} 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);
// Clear listeners for the old marker
if (typeof (window as any).google !== 'undefined' && (window as any).google.maps?.event?.clearInstanceListeners) {
(window as any).google.maps.event.clearInstanceListeners(userMarker.value);
}
}
}
// 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);
// Si el usuario tenía auto_location pero denegó el permiso del navegador,
// lo desmarcamos para que no lo vuelva a intentar infinitamente.
if (authStore.userProfile?.auto_location) {
console.log('🤖 JARVIS: Permiso denegado — desactivando Smart Location.');
authStore.updateProfile({ auto_location: false });
}
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);
// Clear listeners for the old pulse marker
if (typeof (window as any).google !== 'undefined' && (window as any).google.maps?.event?.clearInstanceListeners) {
(window as any).google.maps.event.clearInstanceListeners(optimalStopPulse.value);
}
}
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"
}
}
</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">
{{ t('map.calculatingRoute') }}
</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;"> {{ t('map.mapLoadingError') }}</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 && !showPromos"
class="offers-fab pulse"
@click="showPromos = true"
>
<span class="material-icons">local_offer</span>
<span v-if="couponStore.coupons.length > 0" class="offers-badge">
{{ couponStore.coupons.length }}
</span>
</button>
<!-- Location Button (Animated Pin) - Hidden if Smart Location is active -->
<button
v-if="isLoaded && !authStore.userProfile?.auto_location"
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 && wasSelectedFromMap && !showUberSearch }">
<!-- Floating Triggers -->
<div v-if="!showUberSearch" class="triggers-row">
<!-- Shrunk Trigger (Icon only) - Only if selected from MAP -->
<div
v-if="routeStore.selectedRouteId && wasSelectedFromMap"
class="uber-search-trigger circular"
@click="openUberSearch"
:title="t('map.search')"
>
<span class="material-icons">search</span>
</div>
<!-- Normal Trigger: Compacto con texto -->
<div
v-else
class="uber-search-trigger-compact"
@click="openUberSearch"
>
<span class="material-icons search-icon">directions_bus</span>
<span class="trigger-label">{{ t('map.viewRoutes') }}</span>
</div>
<!-- Nuevo Banner de Parada Cercana Alineado (Redimensionado y con ETA) -->
<Transition name="banner-slide">
<div
v-if="paradaCercana && routeStore.selectedRouteId && !showETACard && !isBannerClosing && wasSelectedFromMap"
class="best-stop-banner-compact"
>
<div class="banner-icon-bg">
<span class="material-icons text-white text-[16px]">directions_bus</span>
</div>
<div class="flex flex-col flex-1 truncate ml-2" style="min-width: 0;">
<span class="text-[9px] uppercase font-bold text-gray-500 dark:text-gray-400 leading-none">{{ t('map.arrivalTime') }}</span>
<span class="trigger-text-compact truncate leading-tight">{{ paradaCercana?.name }}</span>
</div>
<div class="eta-badge">
<template v-if="etaCargando">
<div class="eta-loader"></div>
</template>
<template v-else-if="busesActivos.length > 0">
<span class="eta-value">{{ (busesActivos[0]?.etaMinutos ?? 0) > 0 ? busesActivos[0]?.etaMinutos : '0' }}</span>
<span class="eta-unit">min</span>
</template>
<template v-else>
<span class="eta-unit">-- min</span>
</template>
</div>
<button @click.stop="animateAndReload" class="ml-2 p-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<span class="material-icons text-[18px] text-gray-400 hover:text-red-500">close</span>
</button>
</div>
</Transition>
</div>
<!-- Uber-style Search Panel -->
<Transition name="uber-slide">
<div v-if="showUberSearch" class="uber-search-panel">
<div class="uber-search-header">
<button class="back-btn" @click="closeUberSearch">
<span class="material-icons">arrow_back</span>
</button>
<div class="search-title">{{ t('map.availableRoutes') }}</div>
</div>
<!-- Inputs and Toggle removed per request -->
<div class="search-actions-header">
<!-- Limpiar Mapa button removed per request -->
</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 && wasSelectedFromMap }"
@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">{{ t('map.busRoute') }}</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> <!-- Ends uber-search-container -->
</div> <!-- Ends map-view -->
</div> <!-- Ends map-side -->
<!-- Offers Floating Card (Uber Eats style) - OUTSIDE map-side to avoid anchoring -->
<Transition name="sheet-slide">
<div v-if="showPromos && couponStore.coupons.length > 0" class="offers-sheet">
<!-- Header -->
<div class="sheet-header">
<div class="sheet-title-group">
<span class="sheet-title">{{ t('coupons.title') }}</span>
</div>
<button class="sheet-close" @click="showPromos = false">
<span class="material-icons">close</span>
</button>
</div>
<!-- Card area with nav arrows -->
<div class="sheet-card-area">
<button class="sheet-nav" @click="prevPromo" :disabled="couponStore.coupons.length < 2">
<span class="material-icons">chevron_left</span>
</button>
<Transition name="carousel-slide" mode="out-in">
<div
v-if="currentPromo"
:key="currentPromo.id"
class="sheet-card"
:style="{ backgroundImage: `url(${getImageUrl(currentPromo.image_url, 'coupon')})` }"
@mouseenter="stopCarousel"
@touchstart="stopCarousel"
@mouseleave="startCarousel"
>
<div class="sheet-card-overlay">
<div class="sheet-info">
<span class="sheet-biz-name">{{ currentPromo.business?.name || 'Local' }}</span>
<h3 class="sheet-promo-title">{{ currentPromo.title }}</h3>
<div class="sheet-actions">
<button class="sheet-cta" @click="handlePromoClick(currentPromo)">{{ t('coupons.viewDetails') }}</button>
<span v-if="currentPromo.discount_percentage" class="sheet-discount-tag">-{{ currentPromo.discount_percentage }}%</span>
</div>
</div>
</div>
</div>
</Transition>
<button class="sheet-nav" @click="nextPromo" :disabled="couponStore.coupons.length < 2">
<span class="material-icons">chevron_right</span>
</button>
</div>
<!-- Dots -->
<div class="sheet-dots" v-if="couponStore.coupons.length > 1">
<div
v-for="(_, i) in couponStore.coupons"
:key="i"
class="sheet-dot"
:class="{ 'sheet-dot--active': i === currentCarouselIndex }"
@click="currentCarouselIndex = i; startCarousel()"
></div>
</div>
</div>
</Transition>
<!-- Modal for details removed as per request to eliminate extra markings -->
<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="handleImageError"
/>
<div class="promo-badge-modal">{{ t('map.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" style="flex: 1; width: 100%;" @click="router.push('/business/' + selectedPromo.business_id)">{{ t('business.viewBusiness') }}</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-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;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: calc(100% - 32px);
max-width: 420px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 24px;
z-index: 3000; /* Aumentado para estar sobre todo */
padding: 12px 0 0; /* Padding superior para el título, 0 abajo para que la imagen pegue */
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
color: #000;
overflow: hidden;
transition: all 0.6s cubic-bezier(0.32, 0.72, 0, 1);
}
@media (prefers-color-scheme: dark) {
.offers-sheet {
background: rgba(20, 20, 20, 0.8);
border-color: rgba(255, 255, 255, 0.1);
}
.sheet-title {
color: #FFFFFF !important;
}
}
.sheet-header {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px 12px;
margin-bottom: 0px;
}
.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: 1.5rem;
font-weight: 900;
color: #101820;
text-align: center;
letter-spacing: -0.02em;
}
.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 {
position: absolute;
right: 16px;
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; }
.sheet-card-area {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 0;
min-height: 200px;
}
.sheet-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(8px);
color: #101820;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.sheet-nav:first-of-type { left: 12px; }
.sheet-nav:last-of-type { right: 12px; }
.sheet-nav:disabled { opacity: 0.1; cursor: default; }
.sheet-nav:not(:disabled):hover {
background: var(--active-color);
color: #101820;
transform: scale(1.1);
}
.sheet-nav .material-icons { font-size: 1.125rem; }
@media (prefers-color-scheme: dark) {
.sheet-nav {
background: rgba(255, 255, 255, 0.1);
}
}
.sheet-card {
width: 100%;
height: 200px;
margin: 0;
border-radius: 0;
background-size: cover;
background-position: center;
position: relative;
overflow: hidden;
display: flex;
}
.sheet-card-overlay {
width: 100%;
height: 100%;
background: linear-gradient(
to bottom,
rgba(0,0,0,0.5) 0%,
rgba(0,0,0,0) 30%,
rgba(0,0,0,0) 60%,
rgba(0,0,0,0.85) 100%
);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 1.25rem;
}
.sheet-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sheet-biz-name {
font-size: 0.75rem;
font-weight: 800;
color: #fee715;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sheet-promo-title {
margin: 0;
font-size: 1.1rem;
font-weight: 900;
color: #fff;
line-height: 1.2;
margin-bottom: 0.5rem;
}
.sheet-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.sheet-cta {
background: var(--active-color);
color: #101820;
border: none;
padding: 8px 20px;
border-radius: 100px;
font-size: 0.8125rem;
font-weight: 800;
cursor: pointer;
box-shadow: 0 4px 15px rgba(254, 231, 21, 0.3);
transition: all 0.2s;
}
.sheet-cta:active { transform: scale(0.95); }
.sheet-discount-tag {
background: #f43f5e;
color: #fff;
font-size: 0.75rem;
font-weight: 900;
padding: 0.25rem 0.6rem;
border-radius: 8px;
}
/* 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 - Fluid */
.carousel-slide-enter-active,
.carousel-slide-leave-active {
transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1);
}
.carousel-slide-enter-from { opacity: 0; transform: translateX(40px) scale(0.95); }
.carousel-slide-leave-to { opacity: 0; transform: translateX(-40px) scale(0.95); }
/* 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: 44px; /* Tamaño compacto ajustado */
border-radius: 12px;
display: flex;
align-items: center;
padding: 0 16px;
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%;
max-width: 500px;
}
.uber-search-trigger-compact {
background: var(--active-color) !important;
color: #101820 !important; /* Texto oscuro para el amarillo SIBU */
height: 44px; /* Tamaño del logo / botones header */
border-radius: 12px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
cursor: pointer;
border: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
width: fit-content;
pointer-events: auto;
}
/* En modo claro, el botón es azul, usamos texto blanco */
html.light-theme .uber-search-trigger-compact {
color: #ffffff !important;
}
.uber-search-trigger-compact:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0,0,0,0.3);
}
.uber-search-trigger-compact:active {
transform: scale(0.94);
filter: brightness(0.9);
}
.uber-search-trigger-compact .search-icon {
margin: 0;
font-size: 20px;
color: inherit !important;
}
.trigger-label {
font-size: 0.9rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.02em;
white-space: nowrap;
}
.uber-search-trigger.circular {
width: 44px; /* Mantener cuadrado */
padding: 0;
justify-content: center;
border-radius: 12px;
}
.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);
}
.schedules-btn-floating:active {
transform: scale(0.92);
}
.uber-search-trigger:hover {
transform: translateY(-4px);
background: var(--hover-bg);
}
.uber-search-trigger:active {
transform: scale(0.96);
}
.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;
}
.best-stop-banner-compact {
flex: 1;
background: var(--header-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
padding: 0 10px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
border: 1px solid var(--border-color);
max-width: 100%;
overflow: hidden;
pointer-events: auto;
z-index: 1200;
min-width: 0;
}
/* Animaciones del Banner (Slide de arriba hacia abajo, muy fluido) */
.banner-slide-enter-active {
transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.banner-slide-leave-active {
transition: all 0.4s cubic-bezier(0.7, 0, 0.84, 0);
}
.banner-slide-enter-from,
.banner-slide-leave-to {
transform: translateY(-100%) scale(0.9);
opacity: 0;
}
.banner-slide-enter-to,
.banner-slide-leave-from {
transform: translateY(0) scale(1);
opacity: 1;
}
.banner-icon-bg {
background: #EAB308; /* yellow-500 */
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.trigger-text-compact {
color: var(--text-primary);
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.eta-badge {
background: rgba(234, 179, 8, 0.1); /* yellow-500 with opacity */
color: #EAB308;
padding: 2px 8px;
border-radius: 6px;
display: flex;
align-items: baseline;
gap: 2px;
font-weight: 800;
margin-left: 8px;
border: 1px solid rgba(234, 179, 8, 0.2);
}
.eta-value {
font-size: 1.1rem;
line-height: 1;
}
.eta-unit {
font-size: 0.7rem;
text-transform: uppercase;
}
.eta-loader {
width: 14px;
height: 14px;
border: 2px solid #EAB308;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.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);
overflow-y: auto;
transform-origin: top center;
}
/* 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 - Fluid with scale */
.uber-slide-enter-active {
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.uber-slide-leave-active {
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 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;
transition: bottom 0.6s cubic-bezier(0.32, 0.72, 0, 1);
}
.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:active {
transform: scale(0.85);
background: var(--active-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); }
}
/* Center sheet transition - Fluid Pop */
.sheet-slide-enter-active {
transition: all 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.sheet-slide-leave-active {
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.sheet-slide-enter-from,
.sheet-slide-leave-to {
transform: translate(-50%, -40%) scale(0.85); /* Emerge ligeramente desde abajo hacia el centro */
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: #EAB308; color: #000; padding: 5px 15px; font-weight: 800; border-top-right-radius: 12px; }
.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; }
.business-detail-btn-modal { flex: 1; background: var(--active-color); color: #000; border: none; padding: 15px; border-radius: 10px; font-weight: 800; cursor: pointer; transition: all 0.2s; }
.business-detail-btn-modal:hover { transform: scale(1.02); box-shadow: 0 4px 12px rgba(234, 179, 8, 0.3); }
.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>