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

755 lines
25 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 } = 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);
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;
// 🔥 CRÍTICO
routeStore.wasSelectedFromMap = false;
clearMapMarkers();
limpiarCaminata();
routeStore.clearSelection();
router.replace({ query: {} });
showETACard.value = false;
routePhase.value = 'idle';
// Recarga completa del mapa de Google
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;
}
// 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);
// NOTA: No llamamos a clearMapMarkers() para mantener la ruta si el usuario vuelve
});
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) 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() {
limpiarTodoCentralizado();
// Limpiar también los marcadores de las unidades (buses)
if (unitMarkers.value.size > 0) {
unitMarkers.value.forEach(m => m.setMap(null));
unitMarkers.value.clear();
}
if (userCoords.value) {
reDrawUserMarker();
}
}
function reDrawUserMarker() {
if (!userCoords.value || !map.value) return;
if (userMarker.value && typeof userMarker.value.setMap === 'function') {
userMarker.value.setMap(null);
}
userMarker.value = markRaw(addHtmlMarker(
{ lat: userCoords.value.lat, lng: userCoords.value.lng },
sonarHtml,
{ x: -30, y: -30 }
)!);
}
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);
}
});
*/
// 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
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="routePhase === 'navigating' && !!(paradaCercana && routeStore.selectedRouteId && !isBannerClosing)"
:stop-name="paradaCercana?.name || ''"
:is-loading="etaCargando"
:has-active-buses="busesActivos.length > 0"
:eta-value="busesActivos[0]?.etaMinutos ?? 0"
@close="animateAndReload"
@click="handleBannerClick"
/>
</template>
</SearchOverlay>
</div>
</div>
<!-- COMPONENTIZED PROMOS -->
<PromoCarousel
:is-open="showPromos"
:coupons="couponStore.coupons"
:current-index="currentCarouselIndex"
@update:index="currentCarouselIndex = $event"
@close="showPromos = false"
@prev="currentCarouselIndex = (currentCarouselIndex - 1 + couponStore.coupons.length) % couponStore.coupons.length"
@next="currentCarouselIndex = (currentCarouselIndex + 1) % couponStore.coupons.length"
@pause="stopCarousel"
@resume="startCarousel"
@promo-click="handlePromoClick"
/>
<!-- 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="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: 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>