feat(map): clean stop markers and route dimming
This commit is contained in:
@ -14,16 +14,24 @@ const navItems = [
|
||||
{ name: 'taxi', path: '/taxi', icon: 'directions_bus' }
|
||||
]
|
||||
|
||||
let isNavigating = false
|
||||
const isNavigating = ref(false)
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
const navigateTo = async (path: string) => {
|
||||
// Prevent rapid multiple navigations (debounce guard)
|
||||
if (isNavigating) return
|
||||
if (isNavigating.value) return
|
||||
if (route.path === path) return
|
||||
isNavigating = true
|
||||
router.push(path).finally(() => {
|
||||
setTimeout(() => { isNavigating = false }, 300)
|
||||
})
|
||||
|
||||
try {
|
||||
isNavigating.value = true
|
||||
await router.push(path)
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'NavigationDuplicated') {
|
||||
console.error('SIBU | Error de navegación en el menú inferior:', e)
|
||||
}
|
||||
} finally {
|
||||
// Add a small delay to prevent rapid double-taps
|
||||
setTimeout(() => { isNavigating.value = false }, 300)
|
||||
}
|
||||
}
|
||||
|
||||
const isActive = (path: string) => {
|
||||
@ -59,8 +67,9 @@ onUnmounted(() => {
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="navigateTo(item.path)"
|
||||
:class="{ active: isActive(item.path), 'opacity-50 pointer-events-none': isNavigating }"
|
||||
@click.prevent="navigateTo(item.path)"
|
||||
@touchend.prevent="navigateTo(item.path)"
|
||||
>
|
||||
<span class="material-icons">{{ item.icon }}</span>
|
||||
<span class="nav-label">{{ t('navigation.' + item.name) }}</span>
|
||||
|
||||
@ -27,7 +27,7 @@ export function useDirectionsRoute() {
|
||||
// Función utilitaria para pausar ejecución
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const trazarRuta = async (paradas: Parada[], map: google.maps.Map) => {
|
||||
const trazarRuta = async (paradas: Parada[], map: google.maps.Map, isPast: boolean = false) => {
|
||||
if (!paradas || paradas.length < 2) {
|
||||
errorRuta.value = 'Se requieren al menos 2 paradas para trazar una ruta.';
|
||||
return;
|
||||
@ -75,10 +75,14 @@ export function useDirectionsRoute() {
|
||||
map: map,
|
||||
suppressMarkers: true, // SIBU maneja los suyos propios
|
||||
preserveViewport: true, // No auto centrar en cada tramo para evitar parpadeos visuales
|
||||
polylineOptions: {
|
||||
strokeColor: '#1E40AF', // Azul (Tailwind blue-800)
|
||||
polylineOptions: isPast ? {
|
||||
strokeColor: '#9CA3AF', // Gris Tailwind 400
|
||||
strokeWeight: 3,
|
||||
strokeOpacity: 0.4
|
||||
} : {
|
||||
strokeColor: '#1D4ED8', // Azul Tailwind 700
|
||||
strokeWeight: 5,
|
||||
strokeOpacity: 0.8
|
||||
strokeOpacity: 0.95
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -208,6 +208,160 @@ export function useGoogleMaps() {
|
||||
return marker
|
||||
}
|
||||
|
||||
function addCleanMarker(
|
||||
position: { lat: number; lng: number },
|
||||
title: string,
|
||||
type: 'normal' | 'cercana' | 'origen' | 'destino',
|
||||
onClick?: () => void
|
||||
): google.maps.Marker | null {
|
||||
if (!map.value) {
|
||||
console.error('Map not initialized');
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconoParadaNormal = {
|
||||
path: google.maps.SymbolPath.CIRCLE,
|
||||
fillColor: '#3B82F6', // azul
|
||||
fillOpacity: 1,
|
||||
strokeColor: '#FFFFFF', // borde blanco limpio
|
||||
strokeWeight: 2,
|
||||
scale: 7
|
||||
};
|
||||
|
||||
const iconoParadaCercana = {
|
||||
path: google.maps.SymbolPath.CIRCLE,
|
||||
fillColor: '#F59E0B', // amarillo/naranja
|
||||
fillOpacity: 1,
|
||||
strokeColor: '#FFFFFF',
|
||||
strokeWeight: 3,
|
||||
scale: 10
|
||||
};
|
||||
|
||||
const iconoOrigen = {
|
||||
path: google.maps.SymbolPath.CIRCLE,
|
||||
fillColor: '#10B981', // verde
|
||||
fillOpacity: 1,
|
||||
strokeColor: '#FFFFFF',
|
||||
strokeWeight: 3,
|
||||
scale: 10
|
||||
};
|
||||
|
||||
const iconoDestino = {
|
||||
path: google.maps.SymbolPath.CIRCLE,
|
||||
fillColor: '#EF4444', // rojo
|
||||
fillOpacity: 1,
|
||||
strokeColor: '#FFFFFF',
|
||||
strokeWeight: 3,
|
||||
scale: 10
|
||||
};
|
||||
|
||||
const iconos = {
|
||||
normal: iconoParadaNormal,
|
||||
cercana: iconoParadaCercana,
|
||||
origen: iconoOrigen,
|
||||
destino: iconoDestino
|
||||
};
|
||||
|
||||
const marker = new google.maps.Marker({
|
||||
position,
|
||||
map: map.value,
|
||||
title,
|
||||
icon: iconos[type],
|
||||
});
|
||||
|
||||
if (onClick) {
|
||||
const infoWindow = new google.maps.InfoWindow({
|
||||
content: `
|
||||
<div style="font-family: sans-serif; padding: 6px 10px; font-size: 13px; font-weight: 600; color: #1E3A5F; white-space: nowrap;">
|
||||
🚌 ${title}
|
||||
</div>
|
||||
`
|
||||
});
|
||||
marker.addListener('click', () => {
|
||||
infoWindow.open(map.value, marker);
|
||||
onClick();
|
||||
});
|
||||
}
|
||||
|
||||
if (map.value) {
|
||||
if (!globalOverlays.has(map.value)) {
|
||||
globalOverlays.set(map.value, new Set());
|
||||
}
|
||||
globalOverlays.get(map.value)!.add(marker);
|
||||
}
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
function addHtmlMarker(
|
||||
position: { lat: number; lng: number },
|
||||
htmlContent: string,
|
||||
offset: { x: number; y: number } = { x: 0, y: 0 }
|
||||
) {
|
||||
if (!map.value) return null;
|
||||
|
||||
class CustomOverlay extends google.maps.OverlayView {
|
||||
private div: HTMLElement | null = null;
|
||||
private pos: google.maps.LatLng;
|
||||
|
||||
constructor(pos: google.maps.LatLng) {
|
||||
super();
|
||||
this.pos = pos;
|
||||
}
|
||||
|
||||
onAdd() {
|
||||
const div = document.createElement('div');
|
||||
div.style.position = 'absolute';
|
||||
div.style.cursor = 'pointer';
|
||||
div.innerHTML = htmlContent;
|
||||
this.div = div;
|
||||
const panes = this.getPanes();
|
||||
panes?.overlayMouseTarget.appendChild(div);
|
||||
}
|
||||
|
||||
draw() {
|
||||
const overlayProjection = this.getProjection();
|
||||
const point = overlayProjection.fromLatLngToDivPixel(this.pos);
|
||||
if (point && this.div) {
|
||||
this.div.style.left = (point.x + offset.x) + 'px';
|
||||
this.div.style.top = (point.y + offset.y) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
onRemove() {
|
||||
if (this.div) {
|
||||
try {
|
||||
// Safer element removal
|
||||
if (this.div.parentNode) {
|
||||
this.div.parentNode.removeChild(this.div);
|
||||
} else {
|
||||
this.div.remove();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('CustomOverlay: element already removed or parent mismatch', e);
|
||||
}
|
||||
this.div = null;
|
||||
}
|
||||
}
|
||||
|
||||
setPosition(newPos: { lat: number; lng: number }) {
|
||||
this.pos = new google.maps.LatLng(newPos.lat, newPos.lng);
|
||||
this.draw();
|
||||
}
|
||||
}
|
||||
|
||||
const overlay = new CustomOverlay(new google.maps.LatLng(position.lat, position.lng));
|
||||
overlay.setMap(map.value);
|
||||
|
||||
// Track for cleanup
|
||||
if (!globalOverlays.has(map.value)) {
|
||||
globalOverlays.set(map.value, new Set());
|
||||
}
|
||||
globalOverlays.get(map.value)!.add(overlay as any);
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function addPolyline(path: Array<{ lat: number; lng: number }>): google.maps.Polyline | null {
|
||||
if (!map.value) {
|
||||
console.error('Map not initialized')
|
||||
@ -362,75 +516,6 @@ export function useGoogleMaps() {
|
||||
// with Google Maps' native OverlayView management.
|
||||
}
|
||||
|
||||
function addHtmlMarker(
|
||||
position: { lat: number; lng: number },
|
||||
htmlContent: string,
|
||||
offset: { x: number; y: number } = { x: 0, y: 0 }
|
||||
) {
|
||||
if (!map.value) return null;
|
||||
|
||||
class CustomOverlay extends google.maps.OverlayView {
|
||||
private div: HTMLElement | null = null;
|
||||
private pos: google.maps.LatLng;
|
||||
|
||||
constructor(pos: google.maps.LatLng) {
|
||||
super();
|
||||
this.pos = pos;
|
||||
}
|
||||
|
||||
onAdd() {
|
||||
const div = document.createElement('div');
|
||||
div.style.position = 'absolute';
|
||||
div.style.cursor = 'pointer';
|
||||
div.innerHTML = htmlContent;
|
||||
this.div = div;
|
||||
const panes = this.getPanes();
|
||||
panes?.overlayMouseTarget.appendChild(div);
|
||||
}
|
||||
|
||||
draw() {
|
||||
const overlayProjection = this.getProjection();
|
||||
const point = overlayProjection.fromLatLngToDivPixel(this.pos);
|
||||
if (point && this.div) {
|
||||
this.div.style.left = (point.x + offset.x) + 'px';
|
||||
this.div.style.top = (point.y + offset.y) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
onRemove() {
|
||||
if (this.div) {
|
||||
try {
|
||||
// Safer element removal
|
||||
if (this.div.parentNode) {
|
||||
this.div.parentNode.removeChild(this.div);
|
||||
} else {
|
||||
this.div.remove();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('CustomOverlay: element already removed or parent mismatch', e);
|
||||
}
|
||||
this.div = null;
|
||||
}
|
||||
}
|
||||
|
||||
setPosition(newPos: { lat: number; lng: number }) {
|
||||
this.pos = new google.maps.LatLng(newPos.lat, newPos.lng);
|
||||
this.draw();
|
||||
}
|
||||
}
|
||||
|
||||
const overlay = new CustomOverlay(new google.maps.LatLng(position.lat, position.lng));
|
||||
overlay.setMap(map.value);
|
||||
|
||||
// Track for cleanup
|
||||
if (!globalOverlays.has(map.value)) {
|
||||
globalOverlays.set(map.value, new Set());
|
||||
}
|
||||
globalOverlays.get(map.value)!.add(overlay as any);
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMaps()
|
||||
})
|
||||
@ -444,6 +529,7 @@ export function useGoogleMaps() {
|
||||
addMarker,
|
||||
addHtmlMarker,
|
||||
addNumberedMarker,
|
||||
addCleanMarker,
|
||||
addPolyline,
|
||||
addRoutePolyline,
|
||||
fitBounds,
|
||||
|
||||
@ -44,6 +44,11 @@ const router = createRouter({
|
||||
name: 'taxi',
|
||||
component: () => import('@/views/TaxiView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/shuttle/:id',
|
||||
name: 'shuttle-details',
|
||||
component: () => import('@/views/ShuttleDetalleView.vue'),
|
||||
},
|
||||
|
||||
// ─── Vistas de Descubrir ─────────────────────────────────────────────
|
||||
{
|
||||
|
||||
@ -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 }">
|
||||
|
||||
207
frontend/src/views/ShuttleDetalleView.vue
Normal file
207
frontend/src/views/ShuttleDetalleView.vue
Normal 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>
|
||||
@ -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">
|
||||
|
||||
Reference in New Issue
Block a user