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

842 lines
28 KiB
Vue
Raw Normal View History

2026-02-21 09:53:31 -05:00
<script setup lang="ts">
import { onMounted, ref, watch, nextTick, onUnmounted, shallowRef, markRaw } from "vue";
2026-02-21 09:53:31 -05:00
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useRouteStore } from "@/stores/route";
import { useMapStore } from "@/stores/map";
import { useCouponStore } from "@/stores/coupon";
import { useAuthStore } from "@/stores/auth";
2026-02-21 09:53:31 -05:00
import { useGoogleMaps } from "@/composables/useGoogleMaps";
import { analyticsService } from "@/services/analyticsService";
import { getImageUrl } from "@/utils/imageUrl";
2026-02-21 09:53:31 -05:00
2026-02-26 12:39:15 -05:00
import { useDirectionsRoute } from "@/composables/useDirectionsRoute";
import { useParadaCercana } from "@/composables/useParadaCercana";
import { useETA } from "@/composables/useETA";
import { useFlujoPrincipal } from "@/composables/useFlujoPrincipal";
import { useMapState } from "@/composables/useMapState";
2026-02-21 09:53:31 -05:00
// Optimized Components (Extracted)
import SearchOverlay from "@/components/map/SearchOverlay.vue";
import PromoCarousel from "@/components/map/PromoCarousel.vue";
import ArrivalBanner from "@/components/map/ArrivalBanner.vue";
import ETACard from "@/components/map/ETACard.vue";
import type { BusStop } from '@/types'
2026-02-21 09:53:31 -05:00
const router = useRouter();
const { t } = useI18n();
const routeStore = useRouteStore();
const mapStore = useMapStore();
const couponStore = useCouponStore();
const authStore = useAuthStore();
2026-02-21 09:53:31 -05:00
const { map, isLoaded, error: mapsError, initMap, addHtmlMarker, setCenter, setZoom, addMarker, addCleanMarker } = useGoogleMaps();
const { estasCargando: estasCargandoRuta, errorRuta } = useDirectionsRoute();
const { encontrarParadaCercana, paradaCercana, distanciaMetros, duracionCaminata, limpiarCaminata } = useParadaCercana();
const { calcularETA, busesActivos, cargando: etaCargando } = useETA();
const { procesarSeleccionDeRuta, cargando: flujoCargando } = useFlujoPrincipal();
const { limpiarMapa: limpiarTodoCentralizado } = useMapState();
const showETACard = ref(false);
const routePhase = ref<'idle' | 'eta' | 'navigating'>('idle');
2026-02-26 12:39:15 -05:00
// PERFORMANCE FIX: Use shallowRef for heavy object arrays and Map objects
const promoMarkers = shallowRef<any[]>([]);
const userMarker = shallowRef<any>(null);
const isUpdatingMarkers = ref(false);
// Cancellation token: increment to invalidate any in-flight marker draw
const markerGenerationId = ref(0);
const unitMarkers = shallowRef<Map<string, any>>(new Map());
const unitFetchInterval = ref<any>(null);
const userCoords = ref<{ lat: number; lng: number } | null>(null);
2026-02-21 09:53:31 -05:00
const showUberSearch = ref(false);
const showPromos = ref(false);
const isBannerClosing = ref(false);
const showPromoModal = ref(false);
const selectedPromo = ref<any>(null);
const currentCarouselIndex = ref(0);
const carouselTimer = ref<any>(null);
const isMapMoved = ref(false);
2026-02-21 09:53:31 -05:00
// Search optimization: Simple debounce implementation
// REQUISITO TÉCNICO: Implementar geolocalización automática al iniciar sesión.
function calculateDistance(point1: { lat: number; lng: number }, point2: { lat: number; lng: number }) {
const R = 6371; // Radio de la Tierra en km
const dLat = (point2.lat - point1.lat) * Math.PI / 180;
const dLng = (point2.lng - point1.lng) * Math.PI / 180;
const a =
Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(point1.lat * Math.PI / 180) * Math.cos(point2.lat * Math.PI / 180) *
Math.sin(dLng/2) * Math.sin(dLng/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
2026-02-21 09:53:31 -05:00
function updateIsMapMoved() {
if (!map.value || !userCoords.value) return;
const center = map.value.getCenter();
if (!center) return;
const dist = calculateDistance(
{ lat: center.lat(), lng: center.lng() },
userCoords.value
);
// Si se movió más de 0.1 km (100 metros), mostrar botón
isMapMoved.value = dist > 0.1;
}
2026-02-21 09:53:31 -05:00
function openUberSearch() {
showPromos.value = false;
2026-02-21 09:53:31 -05:00
showUberSearch.value = true;
}
function closeUberSearch() {
showUberSearch.value = false;
}
async function animateAndReload() {
isBannerClosing.value = true;
// 🔥 CRÍTICO: Invalidar cualquier dibujado de markers en vuelo
markerGenerationId.value++;
isUpdatingMarkers.value = false; // liberar el lock para que no quede bloqueado
routeStore.setWasSelectedFromMap(false);
clearMapMarkers();
limpiarCaminata();
routeStore.clearSelection();
router.replace({ query: {} });
showETACard.value = false;
routePhase.value = 'idle';
// Limpieza extra garantizada después de un tick, por si algún await en vuelo
// terminó justo antes e intentó redibujar markers
await nextTick();
clearMapMarkers();
if (map.value) {
setCenter(mapStore.center.lat, mapStore.center.lng);
setZoom(mapStore.zoom);
} else {
await initializeMap();
}
setTimeout(() => {
isBannerClosing.value = false;
}, 500);
}
2026-02-21 09:53:31 -05:00
function handlePromoClick(promo: any) {
selectedPromo.value = promo;
showPromoModal.value = true;
}
function closePromoModal() {
showPromoModal.value = false;
selectedPromo.value = null;
}
async function fetchData(isBackground = false) {
// Estas son actualizaciones asíncronas no urgentes para render principal
Promise.all([
routeStore.loadRoutes(undefined, false, isBackground),
couponStore.loadCoupons({ active_only: true }, isBackground)
]);
// Update super-urgente, dibuja los buses actuales en el mapa al instante
updateActiveUnits();
}
async function handleRefocus() {
// Refrescar datos en fondo de manera silenciosa (isBackground = true)
fetchData(true);
await nextTick();
if (map.value) {
// El mapa sigue vivo — solo redimensionar y actualizar
try {
google.maps.event.trigger(map.value, 'resize');
} catch (_) { /* ignorar si google no disponible */ }
updateActiveUnits();
} else {
// El mapa fue destruido por el browser al suspender la pestaña — reinicializar
console.log('SIBU | Mapa perdido tras refocus, reinicializando...');
if (isLoaded.value) {
await initializeMap();
} else {
const unwatch = watch(isLoaded, async (loaded) => {
if (loaded) { await initializeMap(); unwatch(); }
});
}
}
}
// Map initialization & Lifecycle
onMounted(async () => {
analyticsService.logEvent({ event_name: 'screen_view', properties: { screen_name: 'Map' } })
window.addEventListener('app-refocus', handleRefocus);
await fetchData();
2026-02-21 09:53:31 -05:00
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) {
await routeStore.selectRoute(foundRoute.id, foundRoute.name);
}
}
if (isLoaded.value) {
await initializeMap();
} else {
const unwatch = watch(isLoaded, async (loaded) => {
if (loaded) {
await initializeMap();
unwatch();
}
});
}
unitFetchInterval.value = setInterval(updateActiveUnits, 15000);
startCarousel();
document.addEventListener('visibilitychange', handleVisibilityChange);
2026-02-21 09:53:31 -05:00
});
onUnmounted(() => {
if (unitFetchInterval.value) clearInterval(unitFetchInterval.value);
if (carouselTimer.value) clearInterval(carouselTimer.value);
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('app-refocus', handleRefocus);
2026-02-21 09:53:31 -05:00
});
function handleVisibilityChange() {
if (document.visibilityState === 'visible' && map.value) {
try {
google.maps.event.trigger(map.value, 'resize');
} catch (_) { /* ignorar */ }
updateActiveUnits();
}
}
2026-02-21 09:53:31 -05:00
async function initializeMap() {
await nextTick();
initMap("map", mapStore.center, mapStore.zoom);
if (map.value) {
// PERFORMANCE: Use passive listeners for native events if added (Google Maps doesn't expose this directly easily)
map.value.addListener('click', () => {
if (showETACard.value) handleETACardDismiss();
});
// Detect user interaction with the map to show/hide location button
map.value.addListener('center_changed', updateIsMapMoved);
map.value.addListener('dragstart', () => {
// Forzar visibilidad inmediata en drag si se desea un feedback instantáneo,
// pero el watcher de distancia es el que manda finalmente.
isMapMoved.value = true;
});
2026-02-21 09:53:31 -05:00
}
updatePromoMarkers();
if (authStore.userProfile?.auto_location) {
locateUser();
}
if (routeStore.selectedRouteId && routeStore.wasSelectedFromMap) {
if (routeStore.selectedRouteStops.length === 0) {
await routeStore.loadRouteStops(routeStore.selectedRouteId);
}
updateMapMarkers();
routePhase.value = 'navigating'; // Restaurar en modo navegación al volver
} else {
clearMapMarkers();
}
2026-02-21 09:53:31 -05:00
}
// MARKER RECYCLING & REACTIVITY OPTIMIZATION
2026-02-21 09:53:31 -05:00
function clearMapMarkers() {
limpiarTodoCentralizado();
// Limpiar también los marcadores de las unidades (buses)
if (unitMarkers.value.size > 0) {
unitMarkers.value.forEach(m => m.setMap(null));
unitMarkers.value.clear();
}
if (userCoords.value) {
reDrawUserMarker();
}
}
function reDrawUserMarker() {
if (!userCoords.value || !map.value) return;
if (userMarker.value && typeof userMarker.value.setMap === 'function') {
userMarker.value.setMap(null);
}
userMarker.value = markRaw(addHtmlMarker(
{ lat: userCoords.value.lat, lng: userCoords.value.lng },
sonarHtml,
{ x: -30, y: -30 }
)!);
2026-02-21 09:53:31 -05:00
}
async function updateMapMarkers(skipZoom = false) {
if (!isLoaded.value || !map.value || isUpdatingMarkers.value) return;
2026-02-21 09:53:31 -05:00
const currentRequestRouteId = routeStore.selectedRouteId;
if (!currentRequestRouteId) {
clearMapMarkers();
return;
}
// Capturar el token de generación ANTES de cualquier await
const myGeneration = markerGenerationId.value;
isUpdatingMarkers.value = true;
2026-02-21 09:53:31 -05:00
try {
const selectedRouteObj = routeStore.allRoutes.find(r => r.id === currentRequestRouteId) || { id: currentRequestRouteId, short_name: currentRequestRouteId };
// Guard de generación: si se canceló mientras esperábamos, abortar
if (markerGenerationId.value !== myGeneration) return;
await procesarSeleccionDeRuta(
selectedRouteObj,
routeStore.selectedRouteStops,
map.value,
addCleanMarker,
skipZoom,
(stop: BusStop) => {
// Solo actualizar si aún somos la generación vigente
if (markerGenerationId.value === myGeneration) {
paradaCercana.value = stop;
showETACard.value = true;
}
}
);
// Guard final: verificar que no se canceló durante el await largo
if (markerGenerationId.value !== myGeneration || routeStore.selectedRouteId !== currentRequestRouteId) {
clearMapMarkers();
return;
}
reDrawUserMarker();
if (routeStore.wasSelectedFromMap && !skipZoom) {
await highlightOptimalStopForRoute();
}
} finally {
// Solo liberar el lock si somos la generación actual
if (markerGenerationId.value === myGeneration) {
isUpdatingMarkers.value = false;
}
}
2026-02-21 09:53:31 -05:00
}
async function updatePromoMarkers() {
if (!isLoaded.value) return;
promoMarkers.value.forEach(m => m.setMap(null));
const newMarkers: any[] = [];
2026-02-21 09:53:31 -05:00
const promosWithCoords = couponStore.coupons.filter(c =>
c.is_active && c.business && c.business.latitude && c.business.longitude
);
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',
2026-02-21 09:53:31 -05:00
fillOpacity: 1,
strokeColor: '#FFFFFF',
strokeWeight: 2,
anchor: new google.maps.Point(12, 12),
scale: 2
}
}
);
if (marker) {
const rawMarker = markRaw(marker);
rawMarker.addListener('click', () => handlePromoClick(promo));
newMarkers.push(rawMarker);
2026-02-21 09:53:31 -05:00
}
});
promoMarkers.value = newMarkers;
2026-02-21 09:53:31 -05:00
}
// Carousel logic
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 selectRouteAndClose(route: any) {
if (routeStore.selectedRouteId === route.id) {
showUberSearch.value = false;
highlightOptimalStopForRoute();
return;
}
showUberSearch.value = false;
routeStore.setWasSelectedFromMap(true);
routeStore.selectRoute(route.id, route.name);
2026-02-21 09:53:31 -05:00
}
2026-02-21 09:53:31 -05:00
async function updateActiveUnits() {
if (!isLoaded.value || !routeStore.selectedRouteId) return;
// Llamamos a calcularETA incluso si no hay paradaCercana aún para un chequeo rápido de disponibilidad
await calcularETA(routeStore.selectedRouteId, (paradaCercana.value as BusStop) || null);
2026-02-21 09:53:31 -05:00
}
function locateUser(): Promise<void> {
return new Promise((resolve) => {
if (!navigator.geolocation) {
console.warn('Geolocation no soportado');
return resolve();
}
2026-02-21 09:53:31 -05:00
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords;
userCoords.value = { lat: latitude, lng: longitude };
// Centrar y mostrar
if (map.value) {
setCenter(latitude, longitude);
setZoom(16);
}
reDrawUserMarker();
isMapMoved.value = false;
resolve();
2026-02-21 09:53:31 -05:00
},
(error) => {
console.error('SIBU | Error obteniendo ubicación:', error);
// Si falló por falta de permisos o error y el usuario tenía auto_location activo,
// lo desactivamos para no re-intentar infinitamente
if (authStore.userProfile?.auto_location) {
authStore.updateProfile({ auto_location: false });
}
resolve();
},
// maximumAge: 5 min — al volver del background, usa posición cacheada inmediatamente
// en lugar de esperar que el GPS se reactive (evita timeout al desbloquear pantalla)
{ enableHighAccuracy: false, timeout: 6000, maximumAge: 300000 }
2026-02-21 09:53:31 -05:00
);
});
2026-02-21 09:53:31 -05:00
}
async function highlightOptimalStopForRoute() {
if (!userCoords.value) { await locateUser(); }
else { reDrawUserMarker(); }
2026-02-21 09:53:31 -05:00
if (!userCoords.value || routeStore.selectedRouteStops.length === 0) return;
2026-02-21 09:53:31 -05:00
try {
await encontrarParadaCercana(userCoords.value, routeStore.selectedRouteStops as BusStop[], map.value || undefined);
} catch (e) {
console.error('Error calculating optimal stop:', e);
}
}
2026-02-21 09:53:31 -05:00
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>
`;
2026-02-21 09:53:31 -05:00
// Watch for route selection changes
// Watch for ETA loading to automatically show ETACard if no buses are available
// REVERTED: Stop automatic opening and clearing
/*
watch([etaCargando, () => busesActivos.value.length], ([loading, count]) => {
if (!loading && count === 0 && routeStore.selectedRouteId && routeStore.wasSelectedFromMap) {
showETACard.value = true;
// PROBLEMA 2 & 3: Limpieza automática cuando no hay buses
// Reseteamos el estado de la ruta en el store para que el buscador se limpie
// y el mapa se limpie a través de los watchers existentes.
// Pequeño delay para asegurar que ETACard capture los datos antes de limpiar el store
setTimeout(() => {
if (showETACard.value && busesActivos.value.length === 0 && routeStore.selectedRouteId) {
routeStore.clearSelection();
router.replace({ query: {} });
console.log("SIBU | Ruta autolimpiada por falta de buses");
}
}, 300);
}
});
*/
// Cuando el usuario hace drag-down en el ETACard → pasar a fase 'navigating'
// Esto muestra el ArrivalBanner arriba y las paradas quedan en el mapa
function handleETACardDismiss() {
showETACard.value = false;
routePhase.value = 'navigating';
}
function handleBannerClick() {
// Al tocar el banner superior, volver a mostrar el ETACard
showETACard.value = true;
routePhase.value = 'eta';
}
// Watch for route selection changes
watch(() => routeStore.selectedRouteId, (routeId) => {
if (routeId) {
if (routeStore.wasSelectedFromMap) {
// Al seleccionar ruta: dibujar mapa + mostrar ETACard (fase 'eta')
updateMapMarkers(false);
updateActiveUnits();
showETACard.value = true;
routePhase.value = 'eta';
} else {
clearMapMarkers();
2026-02-21 09:53:31 -05:00
}
} else {
clearMapMarkers();
showETACard.value = false;
routePhase.value = 'idle';
2026-02-21 09:53:31 -05:00
}
});
2026-02-21 09:53:31 -05:00
// Watch for paradaCercana to recalculate ETA as soon as it's identified
// Y abrir el ETACard automáticamente cuando ya tenemos la parada
watch(paradaCercana, (newStop) => {
if (newStop && routeStore.selectedRouteId) {
updateActiveUnits();
}
});
function handleImageError(event: Event) {
(event.target as HTMLImageElement).src = getImageUrl(null, 'coupon');
}
// Watch for user profile to trigger location if preference is enabled OR on auth changes
// Nota: solo se activa si NO tenemos coordenadas aún Y el mapa está cargado.
// Se evita re-disparar por TOKEN_REFRESHED porque onAuthStateChange ya no recarga
// el perfil en ese evento, así que userProfile no cambia al despertar la pantalla.
watch([() => authStore.userProfile?.auto_location, isLoaded], ([canLocate, loaded], [prevCanLocate]) => {
// Solo localizar si: el mapa cargó, auto_location está activo,
// y NO tenemos coords aún (o el mapa se reinicializó sin coords)
if (canLocate && loaded && !userCoords.value) {
// Extra guard: no re-disparar si auto_location no cambió (solo isLoaded cambió)
// Esto previene relocalización innecesaria al volver del background
if (prevCanLocate !== undefined || !userCoords.value) {
console.log('SIBU | Iniciando geolocalización automática...');
locateUser();
}
}
}, { immediate: true });
2026-02-21 09:53:31 -05:00
</script>
<template>
<div class="split-view">
<div class="map-side">
<div class="map-view">
<div v-if="flujoCargando || estasCargandoRuta || errorRuta" class="status-indicator">
<div v-if="flujoCargando || estasCargandoRuta" class="loading-pill">{{ t('map.calculatingRoute') }}</div>
<div v-if="errorRuta" class="error-pill">{{ errorRuta }}</div>
2026-02-26 12:39:15 -05:00
</div>
<div class="map-container">
2026-02-21 09:53:31 -05:00
<div v-if="mapsError" class="error">
<div class="error-content">
<h3> {{ t('map.mapLoadingError') }}</h3>
<div class="error-detail">{{ mapsError }}</div>
2026-02-21 09:53:31 -05:00
</div>
</div>
<div v-else-if="!isLoaded" class="loading">
<p>{{ t('map.loadingMap') }}</p>
</div>
<div id="map" class="map" :style="{ display: isLoaded ? 'block' : 'none' }"></div>
<div class="map-floating-controls">
<button v-if="isLoaded && !showPromos && couponStore.coupons.length > 0" 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>
2026-02-21 09:53:31 -05:00
<!-- SMART LOCATION BUTTON: Hidden by default if auto-location is active, shows up with text when map moved -->
<Transition name="fade-scale">
<button
v-if="isLoaded && (!authStore.userProfile?.auto_location || isMapMoved)"
class="location-btn-smart"
:class="{ 'moved': isMapMoved }"
@click="locateUser"
>
<div class="btn-content">
<span class="material-icons">my_location</span>
<span v-if="isMapMoved" class="btn-text">Volver a Mi Ubicación</span>
</div>
</button>
</Transition>
2026-02-21 09:53:31 -05:00
</div>
</div>
<!-- COMPONENTIZED SEARCH & BANNER -->
<SearchOverlay
:show-panel="showUberSearch"
:is-compact="!!(routeStore.selectedRouteId && routeStore.wasSelectedFromMap)"
:is-route-active="!!routeStore.selectedRouteId"
:all-routes="routeStore.allRoutes"
:selected-route-id="routeStore.selectedRouteId"
:was-selected-from-map="routeStore.wasSelectedFromMap"
@open="openUberSearch"
@close="closeUberSearch"
@select-route="selectRouteAndClose"
>
<template #extra-triggers>
<ArrivalBanner
:is-visible="routePhase === 'navigating' && !!(paradaCercana && routeStore.selectedRouteId && !isBannerClosing)"
:stop-name="paradaCercana?.name || ''"
:is-loading="etaCargando"
:has-active-buses="busesActivos.length > 0"
:eta-value="busesActivos[0]?.etaMinutos ?? 0"
@close="animateAndReload"
@click="handleBannerClick"
/>
</template>
</SearchOverlay>
</div>
</div>
<!-- COMPONENTIZED PROMOS -->
<PromoCarousel
:is-open="showPromos"
:coupons="couponStore.coupons"
:current-index="currentCarouselIndex"
@update:index="currentCarouselIndex = $event"
@close="showPromos = false"
@prev="currentCarouselIndex = (currentCarouselIndex - 1 + couponStore.coupons.length) % couponStore.coupons.length"
@next="currentCarouselIndex = (currentCarouselIndex + 1) % couponStore.coupons.length"
@pause="stopCarousel"
@resume="startCarousel"
@promo-click="handlePromoClick"
/>
2026-02-21 09:53:31 -05:00
<!-- MODALS & CARDS -->
2026-02-21 09:53:31 -05:00
<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>
2026-02-21 09:53:31 -05:00
</div>
<div class="promo-body-modal">
<h2 class="promo-title-modal">{{ selectedPromo.title }}</h2>
<div class="promo-biz">{{ selectedPromo.business?.name }}</div>
2026-02-21 09:53:31 -05:00
<p>{{ selectedPromo.description }}</p>
</div>
<div class="promo-actions-modal">
<button class="business-detail-btn-modal" @click="router.push('/business/' + selectedPromo.business_id)">
{{ t('business.viewBusiness') }}
</button>
2026-02-21 09:53:31 -05:00
</div>
</div>
</div>
</Transition>
<ETACard
:is-open="showETACard"
:stop-name="paradaCercana?.name || ''"
:walk-distance="distanciaMetros"
:walk-duration="duracionCaminata"
:buses="busesActivos"
:is-loading="etaCargando"
@close="handleETACardDismiss"
@refresh="paradaCercana && routeStore.selectedRouteId ? calcularETA(routeStore.selectedRouteId, paradaCercana) : null"
/>
2026-02-21 09:53:31 -05:00
</div>
</template>
<style scoped>
.split-view {
display: flex;
width: 100%;
height: calc(100vh - 64px);
2026-02-21 09:53:31 -05:00
overflow: hidden;
position: relative;
}
.map-side, .map-view, .map-container, .map {
width: 100%;
height: 100%;
position: relative;
}
2026-02-26 12:39:15 -05:00
.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: #1e40af;
2026-02-26 12:39:15 -05:00
color: white;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
animation: pulse 2s infinite;
2026-02-26 12:39:15 -05:00
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }
2026-02-21 09:53:31 -05:00
.error-content { text-align: center; padding: 20px; }
.error-detail { color: var(--text-primary); background: var(--bg-secondary); padding: 15px; border-radius: 8px; margin-top: 10px; }
2026-02-21 09:53:31 -05:00
.map-floating-controls {
position: fixed;
bottom: 85px;
right: 16px;
display: flex;
flex-direction: column;
gap: 16px;
z-index: 1001;
align-items: flex-end; /* Alinea botones a la derecha */
2026-02-21 09:53:31 -05:00
}
.offers-fab {
width: 56px;
height: 56px;
border-radius: 50%;
background: #fee715;
color: #000;
border: none;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
position: relative;
2026-02-21 09:53:31 -05:00
}
.offers-badge {
position: absolute;
top: -5px; right: -5px;
background: #f44336;
color: white;
padding: 2px 6px;
border-radius: 10px;
border: 2px solid #fff;
2026-02-21 09:53:31 -05:00
}
.location-btn-smart {
2026-02-21 09:53:31 -05:00
background: var(--header-bg);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
height: 50px;
border-radius: 25px;
2026-02-21 09:53:31 -05:00
color: var(--active-color);
box-shadow: var(--shadow);
padding: 0 13px;
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
display: flex;
align-items: center;
justify-content: center;
width: 50px; /* Default circular */
overflow: hidden;
}
.location-btn-smart.moved {
width: auto; /* Expand for text */
padding: 0 20px;
background: var(--active-color);
color: #000;
border-color: #000;
}
.btn-content {
display: flex;
align-items: center;
gap: 10px;
white-space: nowrap;
}
.btn-text {
font-size: 0.85rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.fade-scale-enter-active,
.fade-scale-leave-active {
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.fade-scale-enter-from,
.fade-scale-leave-to {
opacity: 0;
transform: scale(0.5) translateY(20px);
2026-02-21 09:53:31 -05:00
}
.promo-modal-overlay {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.8);
z-index: 4000;
2026-02-21 09:53:31 -05:00
display: flex; align-items: center; justify-content: center;
}
2026-02-21 09:53:31 -05:00
.promo-modal-content {
background: var(--card-bg); width: 90%; max-width: 450px;
border-radius: 24px; overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
2026-02-21 09:53:31 -05:00
}
2026-02-21 09:53:31 -05:00
.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; }
2026-02-21 09:53:31 -05:00
.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; }
.business-detail-btn-modal { width: 100%; background: var(--active-color); color: #000; border: none; padding: 15px; border-radius: 12px; font-weight: 800; cursor: pointer; }
2026-02-21 09:53:31 -05:00
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.3s ease; }
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
2026-02-21 09:53:31 -05:00
.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); }
2026-02-21 09:53:31 -05:00
}
@media (max-width: 600px) {
.map-floating-controls { bottom: 100px; }
}
2026-02-21 09:53:31 -05:00
</style>