739 lines
24 KiB
Vue
739 lines
24 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, ref, watch, nextTick, onUnmounted, shallowRef, markRaw, defineAsyncComponent } 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";
|
||
|
||
const ETACard = defineAsyncComponent(() => import("@/components/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 } = useParadaCercana();
|
||
const { calcularETA, busesActivos, cargando: etaCargando } = useETA();
|
||
|
||
const { procesarSeleccionDeRuta } = useFlujoPrincipal();
|
||
const { limpiarMapa: limpiarTodoCentralizado } = useMapState();
|
||
|
||
const showETACard = ref(false);
|
||
|
||
// PERFORMANCE FIX: Use shallowRef for heavy object arrays and Map objects
|
||
const promoMarkers = shallowRef<any[]>([]);
|
||
const userMarker = shallowRef<any>(null);
|
||
const isUpdatingMarkers = ref(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;
|
||
routeStore.clearSelection();
|
||
router.replace({ query: {} });
|
||
|
||
// Limpiar mapa sin recargar
|
||
clearMapMarkers();
|
||
showETACard.value = false;
|
||
|
||
// Recentrar en el usuario si está disponible (soft-reset)
|
||
if (userCoords.value) {
|
||
setCenter(userCoords.value.lat, userCoords.value.lng);
|
||
setZoom(16);
|
||
reDrawUserMarker();
|
||
}
|
||
|
||
setTimeout(() => {
|
||
isBannerClosing.value = false;
|
||
}, 500);
|
||
}
|
||
|
||
function handlePromoClick(promo: any) {
|
||
selectedPromo.value = promo;
|
||
showPromoModal.value = true;
|
||
}
|
||
|
||
function closePromoModal() {
|
||
showPromoModal.value = false;
|
||
selectedPromo.value = null;
|
||
}
|
||
|
||
// Map initialization & Lifecycle
|
||
onMounted(async () => {
|
||
analyticsService.logEvent({ event_name: 'screen_view', properties: { screen_name: 'Map' } })
|
||
|
||
await Promise.all([
|
||
routeStore.loadRoutes(),
|
||
couponStore.loadCoupons({ active_only: true })
|
||
]);
|
||
|
||
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);
|
||
updateActiveUnits();
|
||
startCarousel();
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
if (unitFetchInterval.value) clearInterval(unitFetchInterval.value);
|
||
if (carouselTimer.value) clearInterval(carouselTimer.value);
|
||
clearMapMarkers();
|
||
unitMarkers.value.forEach(m => m.setMap(null));
|
||
unitMarkers.value.clear();
|
||
});
|
||
|
||
async function initializeMap() {
|
||
await nextTick();
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
||
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) showETACard.value = false;
|
||
});
|
||
|
||
// 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;
|
||
});
|
||
}
|
||
|
||
if (routeStore.selectedRouteId && routeStore.selectedRouteStops.length > 0 && routeStore.wasSelectedFromMap) {
|
||
updateMapMarkers();
|
||
} else {
|
||
clearMapMarkers();
|
||
}
|
||
|
||
updatePromoMarkers();
|
||
|
||
if (authStore.userProfile?.auto_location) {
|
||
locateUser();
|
||
}
|
||
|
||
if (routeStore.selectedRouteId && routeStore.wasSelectedFromMap) {
|
||
highlightOptimalStopForRoute();
|
||
}
|
||
}
|
||
|
||
// MARKER RECYCLING & REACTIVITY OPTIMIZATION
|
||
function clearMapMarkers() {
|
||
limpiarTodoCentralizado()
|
||
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 || 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 };
|
||
await procesarSeleccionDeRuta(
|
||
selectedRouteObj,
|
||
stops as BusStop[],
|
||
map.value,
|
||
addCleanMarker,
|
||
skipZoom,
|
||
(stop: BusStop) => {
|
||
paradaCercana.value = stop;
|
||
showETACard.value = true;
|
||
}
|
||
);
|
||
reDrawUserMarker();
|
||
|
||
if (routeStore.selectedRouteId !== currentRequestRouteId) return;
|
||
|
||
if (routeStore.wasSelectedFromMap && !skipZoom) {
|
||
await highlightOptimalStopForRoute();
|
||
}
|
||
} finally {
|
||
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.wasSelectedFromMap = 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('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();
|
||
},
|
||
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 30000 }
|
||
);
|
||
});
|
||
}
|
||
|
||
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);
|
||
} 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("SIBU | Ruta autolimpiada por falta de buses");
|
||
}
|
||
}, 300);
|
||
}
|
||
});
|
||
*/
|
||
|
||
function handleBannerClick() {
|
||
showETACard.value = true;
|
||
// Desaparecer las paradas:
|
||
routeStore.clearSelection();
|
||
router.replace({ query: {} });
|
||
clearMapMarkers();
|
||
}
|
||
|
||
// Watch for route selection changes
|
||
watch(() => routeStore.selectedRouteId, (routeId) => {
|
||
if (routeId) {
|
||
if (routeStore.wasSelectedFromMap) {
|
||
// OPTIMIZACIÓN PROBLEMA 1: Paralelismo Total
|
||
// Iniciamos dibujo y búsqueda de disponibilidad en paralelo
|
||
updateMapMarkers(false);
|
||
updateActiveUnits();
|
||
} else {
|
||
clearMapMarkers();
|
||
}
|
||
} else {
|
||
clearMapMarkers();
|
||
// showETACard.value = false; // REVERTED: Mantener ETACard abierta aunque se limpie el mapa (petición de usuario)
|
||
}
|
||
});
|
||
|
||
// Watch for paradaCercana to recalculate ETA as soon as it's identified
|
||
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
|
||
watch([() => authStore.userProfile?.auto_location, isLoaded], ([canLocate, loaded]) => {
|
||
if (canLocate && loaded && !userCoords.value) {
|
||
console.log('SIBU | 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="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>
|
||
|
||
<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="!!(paradaCercana && routeStore.selectedRouteId && !isBannerClosing && routeStore.wasSelectedFromMap)"
|
||
: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"
|
||
/>
|
||
|
||
<!-- 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"
|
||
@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);
|
||
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: 85px;
|
||
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: 100px; }
|
||
}
|
||
</style>
|