2026-02-21 09:53:31 -05:00
|
|
|
|
<script setup lang="ts">
|
2026-03-02 15:53:36 -05:00
|
|
|
|
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";
|
2026-03-01 12:15:08 -05:00
|
|
|
|
import { useAuthStore } from "@/stores/auth";
|
2026-02-21 09:53:31 -05:00
|
|
|
|
import { useGoogleMaps } from "@/composables/useGoogleMaps";
|
|
|
|
|
|
import { analyticsService } from "@/services/analyticsService";
|
2026-02-25 16:29:13 -05:00
|
|
|
|
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";
|
2026-02-26 13:13:56 -05:00
|
|
|
|
import { useParadaCercana } from "@/composables/useParadaCercana";
|
|
|
|
|
|
import { useETA } from "@/composables/useETA";
|
2026-02-27 10:57:42 -05:00
|
|
|
|
import { useFlujoPrincipal } from "@/composables/useFlujoPrincipal";
|
|
|
|
|
|
import { useMapState } from "@/composables/useMapState";
|
2026-02-21 09:53:31 -05:00
|
|
|
|
|
2026-03-01 17:35:13 -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";
|
|
|
|
|
|
|
2026-03-02 15:53:36 -05:00
|
|
|
|
import ETACard from "@/components/map/ETACard.vue";
|
2026-03-01 17:35:13 -05:00
|
|
|
|
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();
|
2026-03-01 12:15:08 -05:00
|
|
|
|
const authStore = useAuthStore();
|
2026-02-21 09:53:31 -05:00
|
|
|
|
|
2026-03-02 09:23:29 -05:00
|
|
|
|
const { map, isLoaded, error: mapsError, initMap, addHtmlMarker, setCenter, setZoom, addMarker, addCleanMarker } = useGoogleMaps();
|
2026-02-27 10:57:42 -05:00
|
|
|
|
const { estasCargando: estasCargandoRuta, errorRuta } = useDirectionsRoute();
|
2026-03-02 12:55:01 -05:00
|
|
|
|
const { encontrarParadaCercana, paradaCercana, distanciaMetros, duracionCaminata, limpiarCaminata } = useParadaCercana();
|
2026-02-26 13:13:56 -05:00
|
|
|
|
const { calcularETA, busesActivos, cargando: etaCargando } = useETA();
|
|
|
|
|
|
|
2026-02-27 10:57:42 -05:00
|
|
|
|
const { procesarSeleccionDeRuta } = useFlujoPrincipal();
|
|
|
|
|
|
const { limpiarMapa: limpiarTodoCentralizado } = useMapState();
|
|
|
|
|
|
|
2026-02-26 13:13:56 -05:00
|
|
|
|
const showETACard = ref(false);
|
2026-03-02 15:38:52 -05:00
|
|
|
|
const routePhase = ref<'idle' | 'eta' | 'navigating'>('idle');
|
2026-02-26 12:39:15 -05:00
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
// PERFORMANCE FIX: Use shallowRef for heavy object arrays and Map objects
|
|
|
|
|
|
const promoMarkers = shallowRef<any[]>([]);
|
|
|
|
|
|
const userMarker = shallowRef<any>(null);
|
2026-02-28 13:56:56 -05:00
|
|
|
|
const isUpdatingMarkers = ref(false);
|
2026-03-01 17:35:13 -05:00
|
|
|
|
const unitMarkers = shallowRef<Map<string, any>>(new Map());
|
2026-02-28 13:56:56 -05:00
|
|
|
|
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);
|
2026-03-01 17:35:13 -05:00
|
|
|
|
const isBannerClosing = ref(false);
|
|
|
|
|
|
const showPromoModal = ref(false);
|
|
|
|
|
|
const selectedPromo = ref<any>(null);
|
|
|
|
|
|
const currentCarouselIndex = ref(0);
|
|
|
|
|
|
const carouselTimer = ref<any>(null);
|
2026-03-01 21:56:44 -05:00
|
|
|
|
const isMapMoved = ref(false);
|
2026-02-21 09:53:31 -05:00
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
// Search optimization: Simple debounce implementation
|
2026-03-02 09:58:29 -05:00
|
|
|
|
// 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
|
|
|
|
|
2026-03-02 09:58:29 -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() {
|
2026-03-01 17:35:13 -05:00
|
|
|
|
showPromos.value = false;
|
2026-02-21 09:53:31 -05:00
|
|
|
|
showUberSearch.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeUberSearch() {
|
|
|
|
|
|
showUberSearch.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 09:58:29 -05:00
|
|
|
|
async function animateAndReload() {
|
2026-02-28 12:00:38 -05:00
|
|
|
|
isBannerClosing.value = true;
|
2026-03-02 16:22:27 -05:00
|
|
|
|
|
|
|
|
|
|
// 🔥 CRÍTICO
|
|
|
|
|
|
routeStore.wasSelectedFromMap = false;
|
|
|
|
|
|
|
|
|
|
|
|
clearMapMarkers();
|
|
|
|
|
|
limpiarCaminata();
|
|
|
|
|
|
|
2026-03-02 09:35:43 -05:00
|
|
|
|
routeStore.clearSelection();
|
2026-03-02 09:58:29 -05:00
|
|
|
|
router.replace({ query: {} });
|
2026-03-02 16:22:27 -05:00
|
|
|
|
|
2026-03-02 12:40:57 -05:00
|
|
|
|
showETACard.value = false;
|
2026-03-02 15:38:52 -05:00
|
|
|
|
routePhase.value = 'idle';
|
2026-03-02 16:22:27 -05:00
|
|
|
|
|
2026-03-02 09:58:29 -05:00
|
|
|
|
if (userCoords.value) {
|
|
|
|
|
|
setCenter(userCoords.value.lat, userCoords.value.lng);
|
|
|
|
|
|
setZoom(16);
|
|
|
|
|
|
reDrawUserMarker();
|
|
|
|
|
|
}
|
2026-03-02 16:22:27 -05:00
|
|
|
|
|
2026-03-02 09:58:29 -05:00
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
isBannerClosing.value = false;
|
|
|
|
|
|
}, 500);
|
2026-02-28 12:00:38 -05:00
|
|
|
|
}
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
// Map initialization & Lifecycle
|
2026-02-21 09:53:31 -05:00
|
|
|
|
onMounted(async () => {
|
2026-02-25 23:07:14 -05:00
|
|
|
|
analyticsService.logEvent({ event_name: 'screen_view', properties: { screen_name: 'Map' } })
|
2026-02-21 09:53:31 -05:00
|
|
|
|
|
2026-02-26 09:09:25 -05:00
|
|
|
|
await Promise.all([
|
|
|
|
|
|
routeStore.loadRoutes(),
|
|
|
|
|
|
couponStore.loadCoupons({ active_only: true })
|
|
|
|
|
|
]);
|
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);
|
|
|
|
|
|
updateActiveUnits();
|
|
|
|
|
|
startCarousel();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
if (unitFetchInterval.value) clearInterval(unitFetchInterval.value);
|
|
|
|
|
|
if (carouselTimer.value) clearInterval(carouselTimer.value);
|
2026-03-02 16:04:56 -05:00
|
|
|
|
// NOTA: No llamamos a clearMapMarkers() para mantener la ruta si el usuario vuelve
|
2026-02-21 09:53:31 -05:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
async function initializeMap() {
|
|
|
|
|
|
await nextTick();
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
|
|
|
|
|
|
|
|
initMap("map", mapStore.center, mapStore.zoom);
|
|
|
|
|
|
|
|
|
|
|
|
if (map.value) {
|
2026-03-01 17:35:13 -05:00
|
|
|
|
// PERFORMANCE: Use passive listeners for native events if added (Google Maps doesn't expose this directly easily)
|
2026-02-27 12:22:15 -05:00
|
|
|
|
map.value.addListener('click', () => {
|
2026-03-02 15:39:14 -05:00
|
|
|
|
if (showETACard.value) handleETACardDismiss();
|
2026-02-27 12:22:15 -05:00
|
|
|
|
});
|
2026-03-01 21:56:44 -05:00
|
|
|
|
|
|
|
|
|
|
// Detect user interaction with the map to show/hide location button
|
2026-03-02 09:58:29 -05:00
|
|
|
|
map.value.addListener('center_changed', updateIsMapMoved);
|
2026-03-01 21:56:44 -05:00
|
|
|
|
map.value.addListener('dragstart', () => {
|
2026-03-02 09:58:29 -05:00
|
|
|
|
// Forzar visibilidad inmediata en drag si se desea un feedback instantáneo,
|
|
|
|
|
|
// pero el watcher de distancia es el que manda finalmente.
|
2026-03-01 21:56:44 -05:00
|
|
|
|
isMapMoved.value = true;
|
|
|
|
|
|
});
|
2026-02-21 09:53:31 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updatePromoMarkers();
|
|
|
|
|
|
|
2026-03-01 12:15:08 -05:00
|
|
|
|
if (authStore.userProfile?.auto_location) {
|
|
|
|
|
|
locateUser();
|
|
|
|
|
|
}
|
2026-03-02 16:04:56 -05:00
|
|
|
|
|
2026-03-01 14:22:42 -05:00
|
|
|
|
if (routeStore.selectedRouteId && routeStore.wasSelectedFromMap) {
|
2026-03-02 16:04:56 -05:00
|
|
|
|
if (routeStore.selectedRouteStops.length === 0) {
|
|
|
|
|
|
await routeStore.loadRouteStops(routeStore.selectedRouteId);
|
|
|
|
|
|
}
|
|
|
|
|
|
updateMapMarkers();
|
|
|
|
|
|
routePhase.value = 'navigating'; // Restaurar en modo navegación al volver
|
|
|
|
|
|
} else {
|
|
|
|
|
|
clearMapMarkers();
|
2026-03-01 14:22:42 -05:00
|
|
|
|
}
|
2026-02-21 09:53:31 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
// MARKER RECYCLING & REACTIVITY OPTIMIZATION
|
2026-02-21 09:53:31 -05:00
|
|
|
|
function clearMapMarkers() {
|
2026-03-02 16:04:56 -05:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 15:20:49 -05:00
|
|
|
|
if (userCoords.value) {
|
2026-03-01 14:22:42 -05:00
|
|
|
|
reDrawUserMarker();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function reDrawUserMarker() {
|
2026-03-01 15:13:27 -05:00
|
|
|
|
if (!userCoords.value || !map.value) return;
|
2026-03-01 14:22:42 -05:00
|
|
|
|
if (userMarker.value && typeof userMarker.value.setMap === 'function') {
|
|
|
|
|
|
userMarker.value.setMap(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
userMarker.value = markRaw(addHtmlMarker(
|
2026-03-01 14:22:42 -05:00
|
|
|
|
{ lat: userCoords.value.lat, lng: userCoords.value.lng },
|
|
|
|
|
|
sonarHtml,
|
|
|
|
|
|
{ x: -30, y: -30 }
|
2026-03-01 17:35:13 -05:00
|
|
|
|
)!);
|
2026-02-21 09:53:31 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-28 21:39:50 -05:00
|
|
|
|
async function updateMapMarkers(skipZoom = false) {
|
2026-02-27 22:34:08 -05:00
|
|
|
|
if (!isLoaded.value || !map.value || isUpdatingMarkers.value) return;
|
2026-02-21 09:53:31 -05:00
|
|
|
|
|
2026-02-27 22:34:08 -05:00
|
|
|
|
isUpdatingMarkers.value = true;
|
2026-02-21 09:53:31 -05:00
|
|
|
|
const currentRequestRouteId = routeStore.selectedRouteId;
|
|
|
|
|
|
const stops = [...routeStore.selectedRouteStops];
|
|
|
|
|
|
|
2026-02-27 22:34:08 -05:00
|
|
|
|
try {
|
|
|
|
|
|
if (!currentRequestRouteId || stops.length === 0) {
|
|
|
|
|
|
clearMapMarkers();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const selectedRouteObj = routeStore.allRoutes.find(r => r.id === currentRequestRouteId) || { id: currentRequestRouteId, short_name: currentRequestRouteId };
|
2026-03-02 12:34:10 -05:00
|
|
|
|
await procesarSeleccionDeRuta(
|
|
|
|
|
|
selectedRouteObj,
|
|
|
|
|
|
stops as BusStop[],
|
|
|
|
|
|
map.value,
|
|
|
|
|
|
addCleanMarker,
|
|
|
|
|
|
skipZoom,
|
|
|
|
|
|
(stop: BusStop) => {
|
|
|
|
|
|
paradaCercana.value = stop;
|
|
|
|
|
|
showETACard.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
2026-03-01 15:13:27 -05:00
|
|
|
|
reDrawUserMarker();
|
2026-02-27 22:34:08 -05:00
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
if (routeStore.selectedRouteId !== currentRequestRouteId) return;
|
2026-02-27 11:25:04 -05:00
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
if (routeStore.wasSelectedFromMap && !skipZoom) {
|
2026-03-01 14:28:25 -05:00
|
|
|
|
await highlightOptimalStopForRoute();
|
|
|
|
|
|
}
|
2026-02-27 22:34:08 -05:00
|
|
|
|
} finally {
|
|
|
|
|
|
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));
|
2026-03-01 17:35:13 -05:00
|
|
|
|
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",
|
2026-03-01 17:35:13 -05:00
|
|
|
|
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) {
|
2026-03-01 17:35:13 -05:00
|
|
|
|
const rawMarker = markRaw(marker);
|
|
|
|
|
|
rawMarker.addListener('click', () => handlePromoClick(promo));
|
|
|
|
|
|
newMarkers.push(rawMarker);
|
2026-02-21 09:53:31 -05:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-03-01 17:35:13 -05:00
|
|
|
|
promoMarkers.value = newMarkers;
|
2026-02-21 09:53:31 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:35:13 -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) {
|
2026-03-01 14:28:25 -05:00
|
|
|
|
showUberSearch.value = false;
|
2026-03-01 17:35:13 -05:00
|
|
|
|
highlightOptimalStopForRoute();
|
2026-03-01 14:28:25 -05:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-01 17:35:13 -05:00
|
|
|
|
showUberSearch.value = false;
|
2026-03-01 12:38:04 -05:00
|
|
|
|
routeStore.wasSelectedFromMap = true;
|
2026-03-01 17:35:13 -05:00
|
|
|
|
routeStore.selectRoute(route.id, route.name);
|
2026-02-21 09:53:31 -05:00
|
|
|
|
}
|
2026-03-01 17:35:13 -05:00
|
|
|
|
|
2026-02-21 09:53:31 -05:00
|
|
|
|
async function updateActiveUnits() {
|
2026-03-02 09:00:08 -05:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 22:05:55 -05:00
|
|
|
|
function locateUser(): Promise<void> {
|
|
|
|
|
|
return new Promise((resolve) => {
|
2026-03-02 09:58:29 -05:00
|
|
|
|
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;
|
2026-03-01 17:35:13 -05:00
|
|
|
|
userCoords.value = { lat: latitude, lng: longitude };
|
2026-03-02 09:58:29 -05:00
|
|
|
|
|
|
|
|
|
|
// Centrar y mostrar
|
|
|
|
|
|
if (map.value) {
|
|
|
|
|
|
setCenter(latitude, longitude);
|
|
|
|
|
|
setZoom(16);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
reDrawUserMarker();
|
2026-03-02 09:58:29 -05:00
|
|
|
|
isMapMoved.value = false;
|
2026-02-26 22:05:55 -05:00
|
|
|
|
resolve();
|
2026-02-21 09:53:31 -05:00
|
|
|
|
},
|
2026-03-02 09:58:29 -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
|
2026-03-01 12:15:08 -05:00
|
|
|
|
if (authStore.userProfile?.auto_location) {
|
|
|
|
|
|
authStore.updateProfile({ auto_location: false });
|
|
|
|
|
|
}
|
2026-02-26 22:05:55 -05:00
|
|
|
|
resolve();
|
|
|
|
|
|
},
|
2026-03-01 17:35:13 -05:00
|
|
|
|
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 30000 }
|
2026-02-21 09:53:31 -05:00
|
|
|
|
);
|
2026-02-26 22:05:55 -05:00
|
|
|
|
});
|
2026-02-21 09:53:31 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 13:13:56 -05:00
|
|
|
|
async function highlightOptimalStopForRoute() {
|
2026-03-01 17:35:13 -05:00
|
|
|
|
if (!userCoords.value) { await locateUser(); }
|
|
|
|
|
|
else { reDrawUserMarker(); }
|
2026-02-21 09:53:31 -05:00
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
if (!userCoords.value || routeStore.selectedRouteStops.length === 0) return;
|
2026-02-21 09:53:31 -05:00
|
|
|
|
|
2026-03-01 14:22:42 -05:00
|
|
|
|
try {
|
|
|
|
|
|
await encontrarParadaCercana(userCoords.value, routeStore.selectedRouteStops as BusStop[], map.value || undefined);
|
|
|
|
|
|
} catch (e) {
|
2026-03-01 17:35:13 -05:00
|
|
|
|
console.error('Error calculating optimal stop:', e);
|
2026-03-01 14:22:42 -05:00
|
|
|
|
}
|
2026-03-01 17:35:13 -05:00
|
|
|
|
}
|
2026-02-21 09:53:31 -05:00
|
|
|
|
|
2026-03-01 17:35:13 -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
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
// Watch for route selection changes
|
2026-03-01 22:06:42 -05:00
|
|
|
|
// Watch for ETA loading to automatically show ETACard if no buses are available
|
2026-03-02 12:34:10 -05:00
|
|
|
|
// REVERTED: Stop automatic opening and clearing
|
|
|
|
|
|
/*
|
2026-03-01 22:06:42 -05:00
|
|
|
|
watch([etaCargando, () => busesActivos.value.length], ([loading, count]) => {
|
|
|
|
|
|
if (!loading && count === 0 && routeStore.selectedRouteId && routeStore.wasSelectedFromMap) {
|
|
|
|
|
|
showETACard.value = true;
|
2026-03-02 09:00:08 -05:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-03-01 22:06:42 -05:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-03-02 12:34:10 -05:00
|
|
|
|
*/
|
2026-03-01 22:06:42 -05:00
|
|
|
|
|
2026-03-02 15:38:52 -05:00
|
|
|
|
// 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';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 12:40:57 -05:00
|
|
|
|
function handleBannerClick() {
|
2026-03-02 15:38:52 -05:00
|
|
|
|
// Al tocar el banner superior, volver a mostrar el ETACard
|
2026-03-02 12:40:57 -05:00
|
|
|
|
showETACard.value = true;
|
2026-03-02 15:38:52 -05:00
|
|
|
|
routePhase.value = 'eta';
|
2026-03-02 12:40:57 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 09:00:08 -05:00
|
|
|
|
// Watch for route selection changes
|
2026-03-01 17:35:13 -05:00
|
|
|
|
watch(() => routeStore.selectedRouteId, (routeId) => {
|
|
|
|
|
|
if (routeId) {
|
|
|
|
|
|
if (routeStore.wasSelectedFromMap) {
|
2026-03-02 15:38:52 -05:00
|
|
|
|
// Al seleccionar ruta: dibujar mapa + mostrar ETACard (fase 'eta')
|
2026-03-01 17:35:13 -05:00
|
|
|
|
updateMapMarkers(false);
|
2026-03-02 15:38:52 -05:00
|
|
|
|
updateActiveUnits();
|
|
|
|
|
|
showETACard.value = true;
|
|
|
|
|
|
routePhase.value = 'eta';
|
2026-03-01 17:35:13 -05:00
|
|
|
|
} else {
|
|
|
|
|
|
clearMapMarkers();
|
2026-02-21 09:53:31 -05:00
|
|
|
|
}
|
2026-03-01 17:35:13 -05:00
|
|
|
|
} else {
|
|
|
|
|
|
clearMapMarkers();
|
2026-03-02 15:38:52 -05:00
|
|
|
|
showETACard.value = false;
|
|
|
|
|
|
routePhase.value = 'idle';
|
2026-02-21 09:53:31 -05:00
|
|
|
|
}
|
2026-03-01 17:35:13 -05:00
|
|
|
|
});
|
2026-02-21 09:53:31 -05:00
|
|
|
|
|
2026-03-02 09:00:08 -05:00
|
|
|
|
// Watch for paradaCercana to recalculate ETA as soon as it's identified
|
2026-03-02 14:11:53 -05:00
|
|
|
|
// Y abrir el ETACard automáticamente cuando ya tenemos la parada
|
2026-03-02 09:00:08 -05:00
|
|
|
|
watch(paradaCercana, (newStop) => {
|
|
|
|
|
|
if (newStop && routeStore.selectedRouteId) {
|
|
|
|
|
|
updateActiveUnits();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
function handleImageError(event: Event) {
|
|
|
|
|
|
(event.target as HTMLImageElement).src = getImageUrl(null, 'coupon');
|
|
|
|
|
|
}
|
2026-03-02 09:00:08 -05:00
|
|
|
|
|
2026-03-02 09:58:29 -05:00
|
|
|
|
// 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...');
|
2026-03-02 09:00:08 -05:00
|
|
|
|
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">
|
2026-03-01 17:35:13 -05:00
|
|
|
|
<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>
|
2026-02-26 12:39:15 -05:00
|
|
|
|
</div>
|
2026-02-26 22:05:55 -05:00
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
<div class="map-container">
|
2026-02-21 09:53:31 -05:00
|
|
|
|
<div v-if="mapsError" class="error">
|
2026-03-01 17:35:13 -05:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-03-01 09:32:55 -05:00
|
|
|
|
<div class="map-floating-controls">
|
2026-03-01 17:35:13 -05:00
|
|
|
|
<button v-if="isLoaded && !showPromos && couponStore.coupons.length > 0" class="offers-fab pulse" @click="showPromos = true">
|
2026-03-01 00:20:49 -05:00
|
|
|
|
<span class="material-icons">local_offer</span>
|
2026-03-01 17:35:13 -05:00
|
|
|
|
<span v-if="couponStore.coupons.length > 0" class="offers-badge">{{ couponStore.coupons.length }}</span>
|
2026-02-23 15:21:13 -05:00
|
|
|
|
</button>
|
2026-02-21 09:53:31 -05:00
|
|
|
|
|
2026-03-02 09:00:08 -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>
|
|
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
<!-- 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
|
2026-03-02 15:38:52 -05:00
|
|
|
|
:is-visible="routePhase === 'navigating' && !!(paradaCercana && routeStore.selectedRouteId && !isBannerClosing)"
|
2026-03-01 17:35:13 -05:00
|
|
|
|
:stop-name="paradaCercana?.name || ''"
|
|
|
|
|
|
:is-loading="etaCargando"
|
|
|
|
|
|
:has-active-buses="busesActivos.length > 0"
|
|
|
|
|
|
:eta-value="busesActivos[0]?.etaMinutos ?? 0"
|
|
|
|
|
|
@close="animateAndReload"
|
2026-03-02 12:40:57 -05:00
|
|
|
|
@click="handleBannerClick"
|
2026-03-01 17:35:13 -05:00
|
|
|
|
/>
|
|
|
|
|
|
</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
|
|
|
|
|
2026-03-01 17:35:13 -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">
|
2026-03-01 17:35:13 -05:00
|
|
|
|
<img :src="getImageUrl(selectedPromo.image_url, 'coupon')" class="promo-img-modal" @error="handleImageError" />
|
2026-03-01 12:15:08 -05:00
|
|
|
|
<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>
|
2026-02-26 15:36:32 -05:00
|
|
|
|
<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">
|
2026-03-01 17:35:13 -05:00
|
|
|
|
<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>
|
2026-02-26 13:13:56 -05:00
|
|
|
|
|
|
|
|
|
|
<ETACard
|
|
|
|
|
|
:is-open="showETACard"
|
|
|
|
|
|
:stop-name="paradaCercana?.name || ''"
|
|
|
|
|
|
:walk-distance="distanciaMetros"
|
|
|
|
|
|
:walk-duration="duracionCaminata"
|
|
|
|
|
|
:buses="busesActivos"
|
|
|
|
|
|
:is-loading="etaCargando"
|
2026-03-02 15:38:52 -05:00
|
|
|
|
@close="handleETACardDismiss"
|
2026-02-26 13:13:56 -05:00
|
|
|
|
@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%;
|
2026-03-01 17:35:13 -05:00
|
|
|
|
height: calc(100vh - 64px);
|
2026-02-21 09:53:31 -05:00
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
.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 {
|
2026-03-01 17:35:13 -05:00
|
|
|
|
background: #1e40af;
|
2026-02-26 12:39:15 -05:00
|
|
|
|
color: white;
|
|
|
|
|
|
padding: 0.5rem 1rem;
|
|
|
|
|
|
border-radius: 9999px;
|
|
|
|
|
|
font-size: 0.875rem;
|
2026-03-01 17:35:13 -05:00
|
|
|
|
animation: pulse 2s infinite;
|
2026-02-26 12:39:15 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }
|
2026-02-21 09:53:31 -05:00
|
|
|
|
|
2026-03-01 17:35:13 -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
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
.map-floating-controls {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
bottom: 85px;
|
|
|
|
|
|
right: 16px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
z-index: 1001;
|
2026-03-02 09:23:29 -05:00
|
|
|
|
align-items: flex-end; /* Alinea botones a la derecha */
|
2026-02-21 09:53:31 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-23 15:21:13 -05:00
|
|
|
|
.offers-fab {
|
2026-02-24 13:02:19 -05:00
|
|
|
|
width: 56px;
|
|
|
|
|
|
height: 56px;
|
2026-02-23 15:21:13 -05:00
|
|
|
|
border-radius: 50%;
|
2026-02-24 13:02:19 -05:00
|
|
|
|
background: #fee715;
|
|
|
|
|
|
color: #000;
|
2026-02-23 15:21:13 -05:00
|
|
|
|
border: none;
|
2026-02-24 13:02:19 -05:00
|
|
|
|
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
|
|
|
|
|
|
position: relative;
|
2026-02-21 09:53:31 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 13:02:19 -05:00
|
|
|
|
.offers-badge {
|
2026-02-23 15:21:13 -05:00
|
|
|
|
position: absolute;
|
2026-03-01 17:35:13 -05:00
|
|
|
|
top: -5px; right: -5px;
|
2026-02-24 13:02:19 -05:00
|
|
|
|
background: #f44336;
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
padding: 2px 6px;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
border: 2px solid #fff;
|
2026-02-21 09:53:31 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 09:00:08 -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;
|
2026-03-02 09:00:08 -05:00
|
|
|
|
border-radius: 25px;
|
2026-02-21 09:53:31 -05:00
|
|
|
|
color: var(--active-color);
|
|
|
|
|
|
box-shadow: var(--shadow);
|
2026-03-02 09:00:08 -05:00
|
|
|
|
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);
|
2026-03-01 17:35:13 -05:00
|
|
|
|
z-index: 4000;
|
2026-02-21 09:53:31 -05:00
|
|
|
|
display: flex; align-items: center; justify-content: center;
|
|
|
|
|
|
}
|
2026-03-01 17:35:13 -05:00
|
|
|
|
|
2026-02-21 09:53:31 -05:00
|
|
|
|
.promo-modal-content {
|
|
|
|
|
|
background: var(--card-bg); width: 90%; max-width: 450px;
|
2026-03-01 17:35:13 -05:00
|
|
|
|
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-03-01 17:35:13 -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; }
|
2026-03-01 09:58:34 -05:00
|
|
|
|
.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; }
|
2026-03-01 17:35:13 -05:00
|
|
|
|
.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
|
|
|
|
|
2026-03-01 17:35:13 -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
|
|
|
|
|
2026-03-01 17:35:13 -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
|
|
|
|
}
|
2026-02-24 21:55:52 -05:00
|
|
|
|
|
2026-03-01 17:35:13 -05:00
|
|
|
|
@media (max-width: 600px) {
|
|
|
|
|
|
.map-floating-controls { bottom: 100px; }
|
2026-02-24 21:55:52 -05:00
|
|
|
|
}
|
2026-02-21 09:53:31 -05:00
|
|
|
|
</style>
|