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

873 lines
30 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, shallowRef, markRaw } from "vue";
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";
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";
import { useFlujoPrincipal } from "@/composables/useFlujoPrincipal";
import { useMapState } from "@/composables/useMapState";
// 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'
const router = useRouter();
const { t } = useI18n();
const routeStore = useRouteStore();
const mapStore = useMapStore();
const couponStore = useCouponStore();
const authStore = useAuthStore();
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');
// 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);
// Object-based cancel token passed into procesarSeleccionDeRuta so it can abort from within
let currentCancelToken: { cancelled: boolean } = { cancelled: false };
const unitMarkers = shallowRef<Map<string, any>>(new Map());
const unitFetchInterval = ref<any>(null);
const userCoords = ref<{ lat: number; lng: number } | null>(null);
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);
// 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;
}
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;
}
function openUberSearch() {
showPromos.value = false;
showUberSearch.value = true;
}
function closeUberSearch() {
showUberSearch.value = false;
}
async function animateAndReload() {
isBannerClosing.value = true;
// 🔥 CANCELACIÓN TOTAL: invalidar la operación en vuelo desde adentro Y desde afuera
markerGenerationId.value++;
currentCancelToken.cancelled = true; // aborta procesarSeleccionDeRuta desde adentro
currentCancelToken = { cancelled: false }; // prepara un token limpio para la próxima operación
isUpdatingMarkers.value = false; // liberar el lock
routeStore.setWasSelectedFromMap(false);
// Limpiar mapa INMEDIATAMENTE - dos capas de limpieza
clearMapMarkers(); // limpia markers[] y globalOverlays
limpiarTodoCentralizado(); // limpia polylines[], infoWindows[], circles[]
limpiarCaminata();
routeStore.clearSelection();
router.replace({ query: {} });
showETACard.value = false;
routePhase.value = 'idle';
// Segunda pasada tras el tick: por si algún await en vuelo terminó justo antes
await nextTick();
clearMapMarkers();
limpiarTodoCentralizado();
if (map.value) {
setCenter(mapStore.center.lat, mapStore.center.lng);
setZoom(mapStore.zoom);
} else {
await initializeMap();
}
setTimeout(() => {
isBannerClosing.value = false;
}, 500);
}
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('SIB | 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();
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);
});
onUnmounted(() => {
if (unitFetchInterval.value) clearInterval(unitFetchInterval.value);
if (carouselTimer.value) clearInterval(carouselTimer.value);
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('app-refocus', handleRefocus);
});
function handleVisibilityChange() {
if (document.visibilityState === 'visible' && map.value) {
try {
google.maps.event.trigger(map.value, 'resize');
} catch (_) { /* ignorar */ }
updateActiveUnits();
}
}
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;
});
}
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();
}
}
// MARKER RECYCLING & REACTIVITY OPTIMIZATION
function clearMapMarkers() {
// Cancelar cualquier operación async en vuelo ANTES de limpiar el mapa.
// Sin esto, una llamada a encontrarParadaCercana que termine después del cleanup
// dibuja una polilínea huérfana que nunca se limpia.
currentCancelToken.cancelled = true;
currentCancelToken = { cancelled: false }; // token limpio para la próxima operación
isUpdatingMarkers.value = false; // liberar lock
limpiarTodoCentralizado();
limpiarCaminata(); // garantizar que el ref de la polyline de caminata quede limpio
// 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 }
)!);
}
async function updateMapMarkers(skipZoom = false) {
if (!isLoaded.value || !map.value) return;
const currentRequestRouteId = routeStore.selectedRouteId;
if (!currentRequestRouteId) {
clearMapMarkers();
return;
}
// ── CANCELACIÓN EXPLÍCITA: invalidar la operación anterior en vuelo.
// Si NO se hace esto, el token viejo queda en cancelled=false y la
// encontrarParadaCercana anterior dibuja una polilínea huérfana al completarse.
currentCancelToken.cancelled = true;
isUpdatingMarkers.value = false; // liberar lock antes de crear el nuevo
// Capturar el token de generación ANTES de cualquier await
markerGenerationId.value++;
const myGeneration = markerGenerationId.value;
// Crear un token de cancelación para esta operación específica
const myToken = { cancelled: false };
currentCancelToken = myToken;
isUpdatingMarkers.value = true;
try {
const selectedRouteObj = routeStore.allRoutes.find(r => r.id === currentRequestRouteId) || { id: currentRequestRouteId, short_name: currentRequestRouteId };
// Guard de generación: si se canceló mientras esperabamos, abortar
if (myToken.cancelled || markerGenerationId.value !== myGeneration) {
limpiarTodoCentralizado();
return;
}
await procesarSeleccionDeRuta(
selectedRouteObj,
routeStore.selectedRouteStops,
map.value,
addCleanMarker,
skipZoom,
(stop: BusStop) => {
// Solo actualizar si aún somos la generación vigente
if (!myToken.cancelled && markerGenerationId.value === myGeneration) {
paradaCercana.value = stop;
showETACard.value = true;
}
},
myToken // <-- pasar el cancel token al composable
);
// Guard final: verificar que no se canceló durante el await largo
if (myToken.cancelled || markerGenerationId.value !== myGeneration || routeStore.selectedRouteId !== currentRequestRouteId) {
clearMapMarkers();
limpiarTodoCentralizado();
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;
}
}
}
async function updatePromoMarkers() {
if (!isLoaded.value) return;
promoMarkers.value.forEach(m => m.setMap(null));
const newMarkers: any[] = [];
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',
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);
}
});
promoMarkers.value = newMarkers;
}
// 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);
}
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);
}
function locateUser(): Promise<void> {
return new Promise((resolve) => {
if (!navigator.geolocation) {
console.warn('Geolocation no soportado');
return resolve();
}
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();
},
(error) => {
console.error('SIB | 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 }
);
});
}
async function highlightOptimalStopForRoute() {
if (!userCoords.value) { await locateUser(); }
else { reDrawUserMarker(); }
if (!userCoords.value || routeStore.selectedRouteStops.length === 0) return;
try {
await encontrarParadaCercana(userCoords.value, routeStore.selectedRouteStops as BusStop[], map.value || undefined, currentCancelToken);
} catch (e) {
console.error('Error calculating optimal stop:', 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>
`;
// 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("SIB | 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();
}
} else {
clearMapMarkers();
showETACard.value = false;
routePhase.value = 'idle';
}
});
// 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('SIB | Iniciando geolocalización automática...');
locateUser();
}
}
}, { immediate: true });
</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>
</div>
<div class="map-container">
<div v-if="mapsError" class="error">
<div class="error-content">
<h3> {{ t('map.mapLoadingError') }}</h3>
<div class="error-detail">{{ mapsError }}</div>
</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>
<!-- 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>
</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' || routePhase === 'eta') && !!(routeStore.selectedRouteId && !isBannerClosing) && !showETACard"
:stop-name="paradaCercana?.name || routeStore.selectedRouteName || ''"
: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"
/>
<!-- MODALS & CARDS -->
<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" @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 || isUpdatingMarkers || flujoCargando"
@close="handleETACardDismiss"
@refresh="paradaCercana && routeStore.selectedRouteId ? calcularETA(routeStore.selectedRouteId, paradaCercana) : null"
/>
</div>
</template>
<style scoped>
.split-view {
display: flex;
width: 100%;
height: calc(100vh - 64px);
overflow: hidden;
position: relative;
}
.map-side, .map-view, .map-container, .map {
width: 100%;
height: 100%;
position: relative;
}
.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;
color: white;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
animation: pulse 2s infinite;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }
.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; }
.map-floating-controls {
position: fixed;
bottom: calc(95px + var(--safe-area-bottom));
right: 16px;
display: flex;
flex-direction: column;
gap: 16px;
z-index: 1001;
align-items: flex-end; /* Alinea botones a la derecha */
}
.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;
}
.offers-badge {
position: absolute;
top: -5px; right: -5px;
background: #f44336;
color: white;
padding: 2px 6px;
border-radius: 10px;
border: 2px solid #fff;
}
.location-btn-smart {
background: var(--header-bg);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
height: 50px;
border-radius: 25px;
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);
}
.promo-modal-overlay {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.8);
z-index: 4000;
display: flex; align-items: center; justify-content: center;
}
.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);
}
.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; }
.business-detail-btn-modal { width: 100%; background: var(--active-color); color: #000; border: none; padding: 15px; border-radius: 12px; font-weight: 800; cursor: pointer; }
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.3s ease; }
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
.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); }
}
@media (max-width: 600px) {
.map-floating-controls {
bottom: calc(100px + var(--safe-area-bottom));
}
}
</style>