feat(map): clean stop markers and route dimming

This commit is contained in:
2026-02-26 22:05:55 -05:00
parent 1f0229461b
commit c9a260ab23
7 changed files with 535 additions and 363 deletions

View File

@ -25,7 +25,7 @@ const mapStore = useMapStore();
const busStopStore = useBusStopStore();
const couponStore = useCouponStore();
const { map, isLoaded, error: mapsError, initMap, addNumberedMarker, addHtmlMarker, fitBounds, setCenter, setZoom, addMarker, clearAllOverlays } = useGoogleMaps();
const { map, isLoaded, error: mapsError, initMap, addCleanMarker, addHtmlMarker, setCenter, setZoom, addMarker, clearAllOverlays } = useGoogleMaps();
const { trazarRuta, limpiarRuta, estasCargando: estasCargandoRuta, errorRuta } = useDirectionsRoute();
const { encontrarParadaCercana, limpiarCaminata, paradaCercana, distanciaMetros, duracionCaminata } = useParadaCercana();
const { calcularETA, busesActivos, cargando: etaCargando } = useETA();
@ -39,7 +39,6 @@ const polyline = ref<google.maps.Polyline | null>(null);
const walkingPolyline = ref<google.maps.Polyline | null>(null);
const walkingPolylineBorder = ref<google.maps.Polyline | null>(null); // Borde blanco estilo Google Maps
const optimalStopPulse = ref<any>(null); // Radar para la parada óptima
const navigationInfo = ref<{ distance: string, duration: string, targetName: string } | null>(null);
const showRouteDropdown = ref(false);
const routeCardRef = ref<HTMLElement | null>(null);
const isUpdatingMarkers = ref(false);
@ -49,7 +48,7 @@ const userCoords = ref<{ lat: number; lng: number } | null>(null); // Store last
const currentMarkerMode = ref<'dot' | 'pin' | null>(null);
const mappingSequenceId = ref(0); // Atomic ID to prevent race conditions
const alturaNavbar = ref(64);
// Search state
const stopSearchQuery = ref("");
const destinationQuery = ref("");
@ -109,7 +108,6 @@ async function clearAllMapData() {
showRoutesToggle.value = false;
destinationQuery.value = "";
stopSearchQuery.value = "";
navigationInfo.value = null;
// 2. Invalidar cualquier hilo de dibujo en curso
mappingSequenceId.value++;
@ -218,8 +216,14 @@ async function claimPromo() {
}
}
onMounted(async () => {
const navbar = document.querySelector('#navbar-admin') ?? document.querySelector('nav') ?? document.querySelector('header');
if (navbar) {
alturaNavbar.value = navbar.getBoundingClientRect().height;
} else {
alturaNavbar.value = 64;
}
analyticsService.logEvent({ event_name: 'screen_view', properties: { screen_name: 'Map' } })
// Add click outside listener
document.addEventListener('click', handleClickOutside);
@ -343,7 +347,7 @@ async function initializeMap() {
updatePromoMarkers();
// Apply initial styles based on current zoom
updateMarkersStyles(true);
updateMarkersStyles();
}
// Watch for route selection changes
@ -392,12 +396,13 @@ watch(
if (!oldStops || newStops.length !== oldStops.length ||
newStops.some((stop, idx) => stop.id !== oldStops[idx]?.id)) {
console.log('Route stops changed - updating markers')
await updateMapMarkers();
// FLOW REFINEMENT: After markers are loaded, find the optimal entrance stop
// FLOW REFINEMENT: Find the optimal entrance stop first
if (newStops.length > 0) {
highlightOptimalStopForRoute();
await highlightOptimalStopForRoute();
}
await updateMapMarkers();
}
}
}
@ -445,9 +450,6 @@ function clearMapMarkers() {
walkingPolylineBorder.value = null;
}
// Clear navigation info
navigationInfo.value = null;
// Clear optimal pulse
if (optimalStopPulse.value) {
if (typeof optimalStopPulse.value.setMap === 'function') {
@ -474,6 +476,7 @@ async function updateMapMarkers() {
if (!currentRequestRouteId || stops.length === 0) {
clearMapMarkers();
limpiarRuta();
return;
}
@ -489,121 +492,83 @@ async function updateMapMarkers() {
return;
}
const newMarkers: any[] = [];
const path: Array<{ lat: number; lng: number }> = [];
for (let i = 0; i < stops.length; i++) {
const stop = stops[i];
if (!stop) continue;
// Verificación atómica en cada paso
if (mappingSequenceId.value !== thisSeq || !routeStore.selectedRouteId) {
newMarkers.forEach(m => { if (m.setMap) m.setMap(null); });
return;
}
const marker = addNumberedMarker(
{ lat: stop.latitude, lng: stop.longitude },
i + 1,
stop.name,
() => handleBusStopClick(stop)
);
if (marker) newMarkers.push(marker);
path.push({ lat: stop.latitude, lng: stop.longitude });
}
// Final check before committing to the map
if (mappingSequenceId.value !== thisSeq || !routeStore.selectedRouteId) {
newMarkers.forEach(m => { if (m.setMap) m.setMap(null); });
return;
}
clearMapMarkers();
markers.value = newMarkers;
limpiarRuta();
if (path.length > 0) fitBounds(path);
} catch (err) {
console.error('❌ JARVIS: Error en updateMapMarkers:', err);
} finally {
if (mappingSequenceId.value === thisSeq) {
isUpdatingMarkers.value = false;
if (routeStore.selectedRouteId) updateMarkersStyles(true);
let pastStops: any[] = [];
let relevantStops: any[] = [...stops];
if (paradaCercana.value) {
const idx = stops.findIndex(s => s.id === paradaCercana.value?.id);
if (idx > 0) {
pastStops = stops.slice(0, idx + 1); // overlap that 1 point for continuous mapping
relevantStops = stops.slice(idx);
}
}
const newMarkers: any[] = [];
// Paradas del tramo relevante: mostrar con clean markers
relevantStops.forEach((stop, index) => {
let tipo: 'normal' | 'cercana' | 'origen' | 'destino' = 'normal';
if (paradaCercana.value && stop.id === paradaCercana.value.id) tipo = 'cercana';
else if (index === relevantStops.length - 1) tipo = 'destino';
else if (!paradaCercana.value && index === 0) tipo = 'origen';
const marker = addCleanMarker(
{ lat: stop.latitude, lng: stop.longitude },
stop.name,
tipo,
() => handleBusStopClick(stop)
);
if (marker) newMarkers.push(marker);
});
markers.value = newMarkers;
// Dibujar en paralelo ambos tramos
const renderPromises = [];
if (pastStops.length > 1 && map.value) {
renderPromises.push(trazarRuta(pastStops.map((p, i) => ({
id: i, nombre: p.name, latitud: p.latitude, longitud: p.longitude, orden: i
})), map.value, true));
}
if (relevantStops.length > 1 && map.value) {
renderPromises.push(trazarRuta(relevantStops.map((p, i) => ({
id: i, nombre: p.name, latitud: p.latitude, longitud: p.longitude, orden: i
})), map.value, false));
}
await Promise.all(renderPromises);
// Zoom automático al tramo que le importa al usuario
if (map.value) {
const bounds = new google.maps.LatLngBounds();
if (userCoords.value) {
bounds.extend(userCoords.value);
}
relevantStops.forEach(p => bounds.extend({ lat: p.latitude, lng: p.longitude }));
// Timeout para que los directions renderers también ajusten bounds si preserveViewport estaba false (actualmente es true)
setTimeout(() => {
if (map.value && bounds.getNorthEast() && bounds.getSouthWest()) {
map.value.fitBounds(bounds, { top: 80, bottom: 120, left: 20, right: 20 });
}
}, 150);
}
} catch (err) {
console.error('❌ JARVIS: Error en updateMapMarkers:', err);
} finally {
if (mappingSequenceId.value === thisSeq) {
isUpdatingMarkers.value = false;
// updateMarkersStyles NO hace falta para "clean markers". Lo mantenemos en caso sea forzado.
}
}
}
/**
* Optimización de rendimiento: Solo actualiza los iconos si cambiamos de modo (punto vs pin)
* o si se fuerza la actualización (ej: al cargar nueva ruta)
*/
function updateMarkersStyles(force = false) {
if (!map.value || markers.value.length === 0 || !routeStore.selectedRouteId) return;
const currentZoom = map.value.getZoom() || 12;
const newMode = currentZoom >= 15 ? 'pin' : 'dot';
if (!force && currentMarkerMode.value === newMode) return;
currentMarkerMode.value = newMode;
const showNumbers = newMode === 'pin';
console.log(`🤖 JARVIS: Actualizando estilos de marcadores a modo: ${newMode}`);
markers.value.forEach((marker: any, index: number) => {
if (!marker) return;
// Si la secuencia cambió o la ruta desapareció mientras hacíamos esto, abortamos
if (!routeStore.selectedRouteId) {
if (marker.setMap) marker.setMap(null);
return;
}
if (showNumbers) {
// MODO PREMIUM: Círculo Amarillo con Borde Negro y Numero Negro
marker.setIcon({
path: google.maps.SymbolPath.CIRCLE,
fillColor: '#FEE715',
fillOpacity: 1,
strokeColor: '#101820',
strokeWeight: 2.5,
scale: 14, // Tamaño ideal para leer el número dentro
});
marker.setLabel({
text: (index + 1).toString(),
color: '#101820',
fontSize: '13px',
fontWeight: '900',
});
} else {
// MODO COMPACTO: Punto Amarillo brillante con anillo de profundidad
marker.setIcon({
path: google.maps.SymbolPath.CIRCLE,
fillColor: '#FEE715',
fillOpacity: 1,
strokeColor: '#101820',
strokeWeight: 1.5,
scale: 7,
});
marker.setLabel(null);
}
});
// Dibujar la ruta usando Directions API cuando se actualicen los marcadores
if (routeStore.selectedRouteId && map.value) {
const stopsForDirections = markers.value.map((m, i) => {
const pos = m.getPosition();
return {
id: i, // ID temporal para trazar logic
nombre: `Stop ${i+1}`,
latitud: pos.lat(),
longitud: pos.lng(),
orden: i
};
});
trazarRuta(stopsForDirections, map.value);
}
function updateMarkersStyles() {
// Empty space: Clean markers are static and distinct per requirement.
}
// La función drawRouteOnRoad ha sido eliminada por petición del usuario (línea azul removida)
@ -652,11 +617,6 @@ function selectRouteAndClose(routeId: string, routeName: string) {
routeStore.selectRoute(routeId, routeName);
showRouteDropdown.value = false;
showUberSearch.value = false; // Close the expanded search panel
// Si no tenemos ubicación, la pedimos para poder calcular la parada óptima automáticamente
if (!userCoords.value) {
locateUser();
}
}
async function updateActiveUnits() {
if (!isLoaded.value) return;
@ -690,8 +650,13 @@ const optimalSonarHtml = `
</div>
`;
function locateUser() {
if (navigator.geolocation) {
function locateUser(): Promise<void> {
return new Promise((resolve) => {
if (!navigator.geolocation) {
resolve();
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords;
@ -715,13 +680,19 @@ function locateUser() {
sonarHtml,
{ x: -30, y: -30 }
);
resolve();
},
(error) => {
console.error("Error getting location", error);
alert("No se pudo obtener tu ubicación. Por favor, verifica tus permisos de GPS.");
console.warn("SIBU | Geolocalización denegada:", error.message);
resolve();
},
{
enableHighAccuracy: true,
timeout: 8000,
maximumAge: 30000
}
);
}
});
}
/**
@ -730,6 +701,10 @@ function locateUser() {
* y la resalta para el usuario.
*/
async function highlightOptimalStopForRoute() {
if (!userCoords.value) {
await locateUser();
}
if (!userCoords.value || routeStore.selectedRouteStops.length === 0) {
console.warn('🤖 JARVIS: Sin ubicación o paradas para calcular parada óptima.');
return;
@ -744,9 +719,7 @@ async function highlightOptimalStopForRoute() {
const stopObj = paradaCercana.value as BusStop;
console.log(`🤖 JARVIS: Parada óptima detectada: ${stopObj.name}`);
// Centrar mapa
setCenter(stopObj.latitude, stopObj.longitude);
setZoom(17);
// Ya no centramos o hacemos zoom aquí manual porque la nueva gráfica de updateMapMarkers ajusta bounds y engloba location.
// Añadir el PULSO NARANJA
if (optimalStopPulse.value && typeof optimalStopPulse.value.setMap === 'function') {
@ -759,13 +732,6 @@ async function highlightOptimalStopForRoute() {
{ x: -30, y: -30 }
);
// Mini-notificación (Opcional, se cubre ahora también con ETA card)
navigationInfo.value = {
distance: distanciaMetros.value < 1000 ? `${distanciaMetros.value.toFixed(0)} m` : `${(distanciaMetros.value/1000).toFixed(1)} km`,
duration: "Calculada",
targetName: stopObj.name
};
// Calcular ETAs
await calcularETA(routeStore.selectedRouteId!, stopObj);
showETACard.value = true;
@ -837,13 +803,9 @@ function calculateWalkingPath(origin: { lat: number, lng: number }, targetStop:
const route = dirResult.routes[0];
const leg = route.legs?.[0];
// Guardar info de navegación (ETA y Distancia)
// Guardar info de navegación (ETA y Distancia) (Retirado a favor de ETA Card / Parada Cercana Banner)
if (leg) {
navigationInfo.value = {
distance: leg.distance?.text || '---',
duration: leg.duration?.text || '---',
targetName: targetStop.name
};
// console.log('Distancia', leg.distance?.text);
}
if (walkingPolyline.value) walkingPolyline.value.setMap(null);
@ -881,11 +843,6 @@ function calculateWalkingPath(origin: { lat: number, lng: number }, targetStop:
});
}
function clearNavigation() {
clearMapMarkers();
navigationInfo.value = null;
}
</script>
<template>
@ -893,7 +850,7 @@ function clearNavigation() {
<!-- Main Map Container -->
<div class="map-side">
<div class="map-view">
<!-- Status overlay para SIBU Directions API -->
<!-- Status overlay para SIBU Directions API -->
<div v-if="estasCargandoRuta || errorRuta" class="status-indicator">
<div v-if="estasCargandoRuta" class="loading-pill">
Calculando ruta real...
@ -903,6 +860,32 @@ function clearNavigation() {
</div>
</div>
<!-- Banner de Parada Más Cercana Inteligente -->
<div
v-if="paradaCercana && routeStore.selectedRouteId"
class="fixed left-0 right-0 z-40 px-3 transition-transform duration-300 pointer-events-none"
:style="{ top: alturaNavbar + 'px' }"
>
<div class="bg-white dark:bg-gray-900 rounded-b-2xl shadow-xl border-t-4 border-blue-600 border-t-blue-600 p-3 flex items-center gap-3 pointer-events-auto">
<div class="bg-blue-100 dark:bg-blue-900/40 rounded-full p-2 shrink-0">
<span class="material-icons text-blue-600 dark:text-blue-400">directions_bus</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-[11px] text-gray-500 font-bold uppercase">Parada más cercana</p>
<p class="text-sm font-bold text-gray-800 dark:text-white truncate">
{{ paradaCercana?.name }}
</p>
<p class="text-xs text-blue-600 dark:text-blue-400 font-medium whitespace-nowrap overflow-hidden text-ellipsis">
{{ (distanciaMetros && distanciaMetros < 1000) ? Math.round(distanciaMetros) + ' m' : (distanciaMetros ? (distanciaMetros / 1000).toFixed(1) + ' km' : '') }}
<span v-if="duracionCaminata">· {{ Math.round(duracionCaminata / 60) }} min caminando</span>
</p>
</div>
<button @click="paradaCercana = null" class="text-gray-400 hover:text-gray-600 shrink-0 p-1">
<span class="material-icons">close</span>
</button>
</div>
</div>
<div class="map-container">
<!-- Floating Offers Button at exact location -->
<div v-if="mapsError" class="error">
@ -975,27 +958,6 @@ function clearNavigation() {
</div>
<!-- Google Maps Style Navigation Summary Card -->
<Transition name="uber-slide">
<div v-if="navigationInfo" class="navigation-summary-card">
<div class="nav-card-accent"></div>
<div class="nav-content">
<div class="nav-left">
<div class="nav-stats">
<span class="nav-time">{{ navigationInfo.duration }}</span>
<span class="nav-dist">{{ navigationInfo.distance }}</span>
</div>
<div class="nav-destination">Parada: {{ navigationInfo.targetName }}</div>
</div>
<div class="nav-actions">
<button class="nav-btn-close" @click="clearNavigation">
<span class="material-icons">close</span>
</button>
</div>
</div>
</div>
</Transition>
<!-- Uber-style Search Panel -->
<Transition name="uber-slide">
<div v-if="showUberSearch" class="uber-search-panel" :class="{ 'is-focused': isInputFocused }">

View File

@ -0,0 +1,207 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { supabase } from '@/supabase'
import type { Shuttle } from '@/types'
import { getImageUrl } from '@/utils/imageUrl'
import { analyticsService } from '@/services/analyticsService'
const route = useRoute()
const router = useRouter()
const shuttle = ref<Shuttle | null>(null)
const cargando = ref(true)
const error = ref<string | null>(null)
onMounted(async () => {
try {
cargando.value = true
const shuttleId = route.params.id as string
// In a real app we might just get from the store, but directly from Supabase is safe to ensure it always works with Deep Links!
const { data, error: sbError } = await supabase
.from('shuttles')
.select('*')
.eq('id', shuttleId)
.single()
if (sbError) throw sbError
shuttle.value = data
} catch (e: any) {
error.value = 'No se pudo cargar la información del viaje'
console.error('SIBU | Error cargando shuttle:', e)
} finally {
cargando.value = false
}
})
const parsePrice = (priceVal?: number | string | null): string => {
if (!priceVal) return '0.00';
const num = typeof priceVal === 'string' ? parseFloat(priceVal) : priceVal;
return Number.isNaN(num) ? '0.00' : num.toFixed(2);
};
</script>
<template>
<div class="shuttle-detalle-container bg-surface pb-24 min-h-screen relative">
<!-- Header con botón volver -->
<div class="sticky top-0 z-10 bg-surface border-b border-border flex items-center gap-3 px-4 py-3 shadow-sm" style="padding-top: max(env(safe-area-inset-top), 12px);">
<button @click="router.back()" class="p-2 rounded-full hover:bg-hover flex items-center justify-center transition">
<span class="material-icons text-text-primary">arrow_back</span>
</button>
<h1 class="font-bold text-text-primary text-lg truncate flex-1">
{{ shuttle?.company_name || 'Detalle del viaje' }}
</h1>
</div>
<!-- Loading -->
<div v-if="cargando" class="flex flex-col justify-center items-center h-64 gap-3">
<span class="material-icons spin text-4xl" style="color: var(--active-color)">refresh</span>
<p class="text-text-secondary font-medium animate-pulse">Cargando...</p>
</div>
<!-- Error -->
<div v-else-if="error" class="flex flex-col items-center justify-center h-64 px-6 text-center">
<span class="material-icons text-red-500 text-5xl mb-3">error_outline</span>
<p class="text-red-500 font-medium">{{ error }}</p>
<button @click="router.back()" class="mt-6 px-6 py-2 bg-text-primary text-surface font-bold rounded-full shadow hover:opacity-90 transition">
Volver
</button>
</div>
<!-- Contenido completo -->
<div v-else-if="shuttle" class="px-4 py-4 space-y-4 max-w-lg mx-auto animate-fade-in">
<!-- Imagen -->
<div v-if="shuttle.image_url" class="relative w-full h-56 md:h-64 rounded-2xl overflow-hidden shadow-sm">
<img
:src="getImageUrl(shuttle.image_url, 'shuttle')"
:alt="shuttle.company_name"
class="w-full h-full object-cover"
@error="(e) => (e.target as HTMLImageElement).src = getImageUrl(null, 'shuttle')"
/>
<div class="absolute bottom-3 left-3 bg-surface/90 backdrop-blur-sm px-3 py-1 rounded-full text-sm font-bold shadow-sm flex items-center gap-1">
<span class="material-icons text-sm" style="color: var(--active-color)">directions_bus</span>
{{ shuttle.vehicle_type }}
</div>
</div>
<!-- Rutas Origen - Destino prominente -->
<div class="bg-surface rounded-2xl p-5 shadow-sm space-y-3 border border-border">
<div class="flex items-center justify-between">
<div class="flex flex-col flex-1">
<span class="text-xs text-text-tertiary font-semibold mb-1 uppercase tracking-wider">Origen</span>
<span class="font-bold text-text-primary text-lg leading-tight break-words">
{{ shuttle.origin }}
</span>
</div>
<div class="flex flex-col items-center px-4 shrink-0">
<span class="text-xs text-text-secondary font-bold mb-1">{{ shuttle.estimated_duration }}</span>
<div class="w-16 border-t-2 border-dashed border-border relative my-1">
<span class="material-icons absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-text-secondary bg-surface px-1 text-sm">east</span>
</div>
</div>
<div class="flex flex-col flex-1 text-right">
<span class="text-xs text-text-tertiary font-semibold mb-1 uppercase tracking-wider">Destino</span>
<span class="font-bold text-text-primary text-lg leading-tight break-words">
{{ shuttle.destination }}
</span>
</div>
</div>
</div>
<!-- Info principal -->
<div class="bg-surface rounded-2xl p-5 shadow-sm space-y-4 border border-border">
<div>
<h2 class="text-xl font-bold text-text-primary">{{ shuttle.company_name }}</h2>
<p class="text-text-secondary text-sm mt-1 leading-relaxed" v-if="shuttle.description" style="white-space: pre-wrap;">{{ shuttle.description }}</p>
</div>
<div class="grid grid-cols-2 gap-4 pt-4 border-t border-border">
<div class="flex flex-col gap-1">
<span class="text-xs text-text-tertiary uppercase tracking-wider font-semibold flex items-center gap-1"><span class="material-icons text-sm">schedule</span> Hora de salida</span>
<span class="font-semibold text-text-primary bg-bg-secondary p-2 rounded-lg text-sm text-center border border-border">
{{ shuttle.departure_times }}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-text-tertiary uppercase tracking-wider font-semibold flex items-center gap-1"><span class="material-icons text-sm">swap_horiz</span> Tipo de viaje</span>
<span class="font-semibold text-text-primary bg-bg-secondary p-2 rounded-lg text-sm text-center border border-border capitalize">
{{ shuttle.trip_type.replace('_', ' ') }}
</span>
</div>
<div class="flex flex-col gap-1" v-if="shuttle.english_speaking">
<span class="text-xs text-text-tertiary uppercase tracking-wider font-semibold flex items-center gap-1"><span class="material-icons text-sm">g_translate</span> Idiomas</span>
<span class="font-semibold text-text-primary bg-bg-secondary p-2 rounded-lg text-sm text-center border border-border">
Español · English
</span>
</div>
</div>
</div>
<!-- Precio -->
<div class="rounded-2xl p-6 shadow-sm flex items-center justify-between" style="background-color: var(--active-color); color: #101820;">
<div class="text-left">
<p class="text-sm font-semibold opacity-90 mb-1">Precio por pasajero</p>
<div class="flex items-baseline gap-1">
<span class="text-lg font-bold opacity-80">$</span>
<span class="text-4xl font-black tracking-tight">{{ parsePrice(shuttle.price_per_person) }}</span>
</div>
</div>
<div class="p-3 rounded-xl bg-black/10 backdrop-blur-sm" v-if="shuttle.price_private_trip">
<span class="text-xs font-bold uppercase tracking-wider opacity-90 block mb-1">Privado</span>
<span class="font-black text-lg">${{ parsePrice(shuttle.price_private_trip) }}</span>
</div>
</div>
<!-- Contacto -->
<div class="bg-surface rounded-2xl p-5 shadow-sm space-y-4 border border-border mb-8">
<div>
<h3 class="font-bold text-text-primary text-lg">Reserva e Información</h3>
<p class="text-sm text-text-secondary mt-1">Contacta directamente al operador para confirmar disponibilidad.</p>
</div>
<div class="flex flex-col gap-3">
<a v-if="shuttle.contact_whatsapp"
:href="`https://wa.me/${shuttle.contact_whatsapp.replace(/\+/g, '')}?text=Hola,%20me%20gustaría%20información%20sobre%20el%20shuttle%20de%20${shuttle.origin}%20a%20${shuttle.destination}`"
target="_blank"
class="flex justify-center items-center gap-2 p-3.5 bg-[#25D366] text-white rounded-xl font-bold hover:opacity-90 transition active:scale-95"
@click="analyticsService.logEvent({ event_name: 'shuttle_contact', item_id: shuttle.id, properties: { action: 'whatsapp' } })"
>
<span class="material-icons">chat</span>
Reservar por WhatsApp
</a>
<a v-if="shuttle.phone_number"
:href="`tel:${shuttle.phone_number}`"
class="flex justify-center items-center gap-2 p-3.5 bg-bg-secondary text-text-primary rounded-xl font-bold hover:bg-hover transition active:scale-95 border border-border"
@click="analyticsService.logEvent({ event_name: 'shuttle_contact', item_id: shuttle.id, properties: { action: 'call' } })"
>
<span class="material-icons">phone_in_talk</span>
Llamar al Operador
</a>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.spin {
animation: spin 1s infinite linear;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, ref, computed, watch, nextTick } from 'vue'
import { onMounted, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useTaxiStore } from '@/stores/taxi'
import { useShuttleStore } from '@/stores/shuttle'
@ -23,25 +24,13 @@ const shifts = ['all', 'dia', 'tarde', 'noche']
// Shuttle Filters
const shuttleRouteFilter = ref('all')
const shuttleTypeFilter = ref('all')
const expandedShuttleId = ref<string | null>(null)
const router = useRouter()
const shuttleRefs = ref<Record<string, any>>({})
const setShuttleRef = (el: any, id: string) => {
if (el) shuttleRefs.value[id] = el
}
watch(expandedShuttleId, async (newVal) => {
if (newVal) {
await nextTick()
const el = shuttleRefs.value[newVal]
if (el) {
// Small timeout to wait for the CSS height transition if any
setTimeout(() => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100)
}
}
})
const shuttleRoutes = computed(() => {
const routes = shuttleStore.shuttles.map(s => `${s.origin} - ${s.destination}`)
@ -88,23 +77,6 @@ const handleCall = (taxi: Taxi) => {
window.location.href = `tel:${taxi.phone_number}`
}
const handleReserve = (shuttle: Shuttle) => {
analyticsService.logEvent({
event_name: 'shuttle_contact',
item_id: shuttle.id,
properties: { action: 'whatsapp', route: shuttle.route_name }
})
const message = encodeURIComponent(`Hola SIBU, me gustaría reservar un cupo para la ruta: ${shuttle.route_name}.`)
window.open(`https://wa.me/${shuttle.contact_whatsapp.replace(/\+/g, '')}?text=${message}`, '_blank')
}
const handleCallShuttle = (shuttle: Shuttle) => {
analyticsService.logEvent({
event_name: 'shuttle_contact',
item_id: shuttle.id,
properties: { action: 'call', route: shuttle.route_name }
})
}
function getShiftLabel(shift: string) {
if (shift === 'dia') return t('taxi.dayShift')
@ -275,26 +247,15 @@ function getShiftLabel(shift: string) {
<div v-else class="shuttles-grid">
<!-- OVERLAY BACKDROP when a card is expanded -->
<Teleport to="body">
<div
v-if="expandedShuttleId !== null"
class="shuttle-modal-backdrop"
@click="expandedShuttleId = null"
></div>
</Teleport>
<div
v-for="shuttle in filteredShuttles"
:key="shuttle.id"
v-memo="[shuttle.id]"
:ref="el => setShuttleRef(el, shuttle.id)"
class="shuttle-card"
:class="{ expanded: expandedShuttleId === shuttle.id }"
@click="() => {
expandedShuttleId = expandedShuttleId === shuttle.id ? null : shuttle.id;
if (expandedShuttleId === shuttle.id) {
analyticsService.logEvent({ event_name: 'shuttle_view', item_id: shuttle.id });
}
analyticsService.logEvent({ event_name: 'shuttle_view_detailed', item_id: shuttle.id });
router.push(`/shuttle/${shuttle.id}`);
}"
>
<img
@ -325,72 +286,10 @@ function getShiftLabel(shift: string) {
{{ shuttle.vehicle_type }}
</div>
<div class="expand-indicator">
<span class="material-icons">{{ expandedShuttleId === shuttle.id ? 'expand_less' : 'expand_more' }}</span>
<span class="material-icons">chevron_right</span>
</div>
</div>
</div> <!-- Close shuttle-main-info -->
<!-- EXPANDED CONTENT -->
<div class="shuttle-details" v-if="expandedShuttleId === shuttle.id" @click.stop>
<div class="shuttle-separator"></div>
<div class="shuttle-body">
<div class="info-row">
<span class="material-icons">schedule</span>
<div>
<p class="label">{{ t('shuttle.duration') }}</p>
<p class="value">{{ shuttle.estimated_duration }}</p>
</div>
</div>
<div class="info-row">
<span class="material-icons">event</span>
<div>
<p class="label">{{ t('shuttle.departure') }}</p>
<p class="value">{{ shuttle.departure_times }}</p>
</div>
</div>
<div class="info-row" v-if="shuttle.english_speaking">
<span class="material-icons">g_translate</span>
<div>
<p class="label">IDIOMA</p>
<p class="value">Español · English</p>
</div>
</div>
</div>
<!-- Precios prominentes -->
<div class="price-block">
<div class="price-row-main">
<span class="price-amount-big">${{ shuttle.price_per_person }}</span>
<span class="price-label-big">{{ t('shuttle.perPerson') }}</span>
</div>
<div class="price-row-secondary" v-if="shuttle.price_private_trip">
<span class="material-icons price-icon-secondary">directions_car</span>
<span class="price-amount-secondary">${{ shuttle.price_private_trip }}</span>
<span class="price-label-secondary">viaje privado</span>
</div>
</div>
<!-- Botones de contacto (full width) -->
<div class="contact-buttons">
<a
v-if="shuttle.phone_number"
:href="'tel:' + shuttle.phone_number"
class="contact-btn btn-call"
@click.stop="handleCallShuttle(shuttle)"
>
<span class="material-icons">phone_in_talk</span>
<span>Llamar</span>
</a>
<button
class="contact-btn btn-whatsapp"
@click.stop="handleReserve(shuttle)"
>
<span class="material-icons">chat</span>
<span>WhatsApp</span>
</button>
</div>
</div>
</div>
<div v-if="filteredShuttles.length === 0" class="empty-state">