1922 lines
53 KiB
Vue
1922 lines
53 KiB
Vue
<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>
|