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' }
|
{ 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)
|
// Prevent rapid multiple navigations (debounce guard)
|
||||||
if (isNavigating) return
|
if (isNavigating.value) return
|
||||||
if (route.path === path) return
|
if (route.path === path) return
|
||||||
isNavigating = true
|
|
||||||
router.push(path).finally(() => {
|
try {
|
||||||
setTimeout(() => { isNavigating = false }, 300)
|
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) => {
|
const isActive = (path: string) => {
|
||||||
@ -59,8 +67,9 @@ onUnmounted(() => {
|
|||||||
v-for="item in navItems"
|
v-for="item in navItems"
|
||||||
:key="item.name"
|
:key="item.name"
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path), 'opacity-50 pointer-events-none': isNavigating }"
|
||||||
@click="navigateTo(item.path)"
|
@click.prevent="navigateTo(item.path)"
|
||||||
|
@touchend.prevent="navigateTo(item.path)"
|
||||||
>
|
>
|
||||||
<span class="material-icons">{{ item.icon }}</span>
|
<span class="material-icons">{{ item.icon }}</span>
|
||||||
<span class="nav-label">{{ t('navigation.' + item.name) }}</span>
|
<span class="nav-label">{{ t('navigation.' + item.name) }}</span>
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export function useDirectionsRoute() {
|
|||||||
// Función utilitaria para pausar ejecución
|
// Función utilitaria para pausar ejecución
|
||||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
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) {
|
if (!paradas || paradas.length < 2) {
|
||||||
errorRuta.value = 'Se requieren al menos 2 paradas para trazar una ruta.';
|
errorRuta.value = 'Se requieren al menos 2 paradas para trazar una ruta.';
|
||||||
return;
|
return;
|
||||||
@ -75,10 +75,14 @@ export function useDirectionsRoute() {
|
|||||||
map: map,
|
map: map,
|
||||||
suppressMarkers: true, // SIBU maneja los suyos propios
|
suppressMarkers: true, // SIBU maneja los suyos propios
|
||||||
preserveViewport: true, // No auto centrar en cada tramo para evitar parpadeos visuales
|
preserveViewport: true, // No auto centrar en cada tramo para evitar parpadeos visuales
|
||||||
polylineOptions: {
|
polylineOptions: isPast ? {
|
||||||
strokeColor: '#1E40AF', // Azul (Tailwind blue-800)
|
strokeColor: '#9CA3AF', // Gris Tailwind 400
|
||||||
|
strokeWeight: 3,
|
||||||
|
strokeOpacity: 0.4
|
||||||
|
} : {
|
||||||
|
strokeColor: '#1D4ED8', // Azul Tailwind 700
|
||||||
strokeWeight: 5,
|
strokeWeight: 5,
|
||||||
strokeOpacity: 0.8
|
strokeOpacity: 0.95
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -208,6 +208,160 @@ export function useGoogleMaps() {
|
|||||||
return marker
|
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 {
|
function addPolyline(path: Array<{ lat: number; lng: number }>): google.maps.Polyline | null {
|
||||||
if (!map.value) {
|
if (!map.value) {
|
||||||
console.error('Map not initialized')
|
console.error('Map not initialized')
|
||||||
@ -362,75 +516,6 @@ export function useGoogleMaps() {
|
|||||||
// with Google Maps' native OverlayView management.
|
// 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(() => {
|
onMounted(() => {
|
||||||
loadMaps()
|
loadMaps()
|
||||||
})
|
})
|
||||||
@ -444,6 +529,7 @@ export function useGoogleMaps() {
|
|||||||
addMarker,
|
addMarker,
|
||||||
addHtmlMarker,
|
addHtmlMarker,
|
||||||
addNumberedMarker,
|
addNumberedMarker,
|
||||||
|
addCleanMarker,
|
||||||
addPolyline,
|
addPolyline,
|
||||||
addRoutePolyline,
|
addRoutePolyline,
|
||||||
fitBounds,
|
fitBounds,
|
||||||
|
|||||||
@ -44,6 +44,11 @@ const router = createRouter({
|
|||||||
name: 'taxi',
|
name: 'taxi',
|
||||||
component: () => import('@/views/TaxiView.vue'),
|
component: () => import('@/views/TaxiView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/shuttle/:id',
|
||||||
|
name: 'shuttle-details',
|
||||||
|
component: () => import('@/views/ShuttleDetalleView.vue'),
|
||||||
|
},
|
||||||
|
|
||||||
// ─── Vistas de Descubrir ─────────────────────────────────────────────
|
// ─── Vistas de Descubrir ─────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
|||||||
@ -25,7 +25,7 @@ const mapStore = useMapStore();
|
|||||||
const busStopStore = useBusStopStore();
|
const busStopStore = useBusStopStore();
|
||||||
const couponStore = useCouponStore();
|
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 { trazarRuta, limpiarRuta, estasCargando: estasCargandoRuta, errorRuta } = useDirectionsRoute();
|
||||||
const { encontrarParadaCercana, limpiarCaminata, paradaCercana, distanciaMetros, duracionCaminata } = useParadaCercana();
|
const { encontrarParadaCercana, limpiarCaminata, paradaCercana, distanciaMetros, duracionCaminata } = useParadaCercana();
|
||||||
const { calcularETA, busesActivos, cargando: etaCargando } = useETA();
|
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 walkingPolyline = ref<google.maps.Polyline | null>(null);
|
||||||
const walkingPolylineBorder = ref<google.maps.Polyline | null>(null); // Borde blanco estilo Google Maps
|
const walkingPolylineBorder = ref<google.maps.Polyline | null>(null); // Borde blanco estilo Google Maps
|
||||||
const optimalStopPulse = ref<any>(null); // Radar para la parada óptima
|
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 showRouteDropdown = ref(false);
|
||||||
const routeCardRef = ref<HTMLElement | null>(null);
|
const routeCardRef = ref<HTMLElement | null>(null);
|
||||||
const isUpdatingMarkers = ref(false);
|
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 currentMarkerMode = ref<'dot' | 'pin' | null>(null);
|
||||||
const mappingSequenceId = ref(0); // Atomic ID to prevent race conditions
|
const mappingSequenceId = ref(0); // Atomic ID to prevent race conditions
|
||||||
|
|
||||||
|
const alturaNavbar = ref(64);
|
||||||
// Search state
|
// Search state
|
||||||
const stopSearchQuery = ref("");
|
const stopSearchQuery = ref("");
|
||||||
const destinationQuery = ref("");
|
const destinationQuery = ref("");
|
||||||
@ -109,7 +108,6 @@ async function clearAllMapData() {
|
|||||||
showRoutesToggle.value = false;
|
showRoutesToggle.value = false;
|
||||||
destinationQuery.value = "";
|
destinationQuery.value = "";
|
||||||
stopSearchQuery.value = "";
|
stopSearchQuery.value = "";
|
||||||
navigationInfo.value = null;
|
|
||||||
|
|
||||||
// 2. Invalidar cualquier hilo de dibujo en curso
|
// 2. Invalidar cualquier hilo de dibujo en curso
|
||||||
mappingSequenceId.value++;
|
mappingSequenceId.value++;
|
||||||
@ -218,8 +216,14 @@ async function claimPromo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
onMounted(async () => {
|
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' } })
|
analyticsService.logEvent({ event_name: 'screen_view', properties: { screen_name: 'Map' } })
|
||||||
// Add click outside listener
|
// Add click outside listener
|
||||||
document.addEventListener('click', handleClickOutside);
|
document.addEventListener('click', handleClickOutside);
|
||||||
@ -343,7 +347,7 @@ async function initializeMap() {
|
|||||||
updatePromoMarkers();
|
updatePromoMarkers();
|
||||||
|
|
||||||
// Apply initial styles based on current zoom
|
// Apply initial styles based on current zoom
|
||||||
updateMarkersStyles(true);
|
updateMarkersStyles();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for route selection changes
|
// Watch for route selection changes
|
||||||
@ -392,12 +396,13 @@ watch(
|
|||||||
if (!oldStops || newStops.length !== oldStops.length ||
|
if (!oldStops || newStops.length !== oldStops.length ||
|
||||||
newStops.some((stop, idx) => stop.id !== oldStops[idx]?.id)) {
|
newStops.some((stop, idx) => stop.id !== oldStops[idx]?.id)) {
|
||||||
console.log('Route stops changed - updating markers')
|
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) {
|
if (newStops.length > 0) {
|
||||||
highlightOptimalStopForRoute();
|
await highlightOptimalStopForRoute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await updateMapMarkers();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -445,9 +450,6 @@ function clearMapMarkers() {
|
|||||||
walkingPolylineBorder.value = null;
|
walkingPolylineBorder.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear navigation info
|
|
||||||
navigationInfo.value = null;
|
|
||||||
|
|
||||||
// Clear optimal pulse
|
// Clear optimal pulse
|
||||||
if (optimalStopPulse.value) {
|
if (optimalStopPulse.value) {
|
||||||
if (typeof optimalStopPulse.value.setMap === 'function') {
|
if (typeof optimalStopPulse.value.setMap === 'function') {
|
||||||
@ -474,6 +476,7 @@ async function updateMapMarkers() {
|
|||||||
|
|
||||||
if (!currentRequestRouteId || stops.length === 0) {
|
if (!currentRequestRouteId || stops.length === 0) {
|
||||||
clearMapMarkers();
|
clearMapMarkers();
|
||||||
|
limpiarRuta();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -489,121 +492,83 @@ async function updateMapMarkers() {
|
|||||||
return;
|
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();
|
clearMapMarkers();
|
||||||
markers.value = newMarkers;
|
limpiarRuta();
|
||||||
|
|
||||||
if (path.length > 0) fitBounds(path);
|
let pastStops: any[] = [];
|
||||||
|
let relevantStops: any[] = [...stops];
|
||||||
|
|
||||||
} catch (err) {
|
if (paradaCercana.value) {
|
||||||
console.error('❌ JARVIS: Error en updateMapMarkers:', err);
|
const idx = stops.findIndex(s => s.id === paradaCercana.value?.id);
|
||||||
} finally {
|
if (idx > 0) {
|
||||||
if (mappingSequenceId.value === thisSeq) {
|
pastStops = stops.slice(0, idx + 1); // overlap that 1 point for continuous mapping
|
||||||
isUpdatingMarkers.value = false;
|
relevantStops = stops.slice(idx);
|
||||||
if (routeStore.selectedRouteId) updateMarkersStyles(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function updateMarkersStyles() {
|
||||||
* Optimización de rendimiento: Solo actualiza los iconos si cambiamos de modo (punto vs pin)
|
// Empty space: Clean markers are static and distinct per requirement.
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// La función drawRouteOnRoad ha sido eliminada por petición del usuario (línea azul removida)
|
// 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);
|
routeStore.selectRoute(routeId, routeName);
|
||||||
showRouteDropdown.value = false;
|
showRouteDropdown.value = false;
|
||||||
showUberSearch.value = false; // Close the expanded search panel
|
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() {
|
async function updateActiveUnits() {
|
||||||
if (!isLoaded.value) return;
|
if (!isLoaded.value) return;
|
||||||
@ -690,8 +650,13 @@ const optimalSonarHtml = `
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function locateUser() {
|
function locateUser(): Promise<void> {
|
||||||
if (navigator.geolocation) {
|
return new Promise((resolve) => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(position) => {
|
(position) => {
|
||||||
const { latitude, longitude } = position.coords;
|
const { latitude, longitude } = position.coords;
|
||||||
@ -715,13 +680,19 @@ function locateUser() {
|
|||||||
sonarHtml,
|
sonarHtml,
|
||||||
{ x: -30, y: -30 }
|
{ x: -30, y: -30 }
|
||||||
);
|
);
|
||||||
|
resolve();
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
console.error("Error getting location", error);
|
console.warn("SIBU | Geolocalización denegada:", error.message);
|
||||||
alert("No se pudo obtener tu ubicación. Por favor, verifica tus permisos de GPS.");
|
resolve();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 8000,
|
||||||
|
maximumAge: 30000
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -730,6 +701,10 @@ function locateUser() {
|
|||||||
* y la resalta para el usuario.
|
* y la resalta para el usuario.
|
||||||
*/
|
*/
|
||||||
async function highlightOptimalStopForRoute() {
|
async function highlightOptimalStopForRoute() {
|
||||||
|
if (!userCoords.value) {
|
||||||
|
await locateUser();
|
||||||
|
}
|
||||||
|
|
||||||
if (!userCoords.value || routeStore.selectedRouteStops.length === 0) {
|
if (!userCoords.value || routeStore.selectedRouteStops.length === 0) {
|
||||||
console.warn('🤖 JARVIS: Sin ubicación o paradas para calcular parada óptima.');
|
console.warn('🤖 JARVIS: Sin ubicación o paradas para calcular parada óptima.');
|
||||||
return;
|
return;
|
||||||
@ -744,9 +719,7 @@ async function highlightOptimalStopForRoute() {
|
|||||||
const stopObj = paradaCercana.value as BusStop;
|
const stopObj = paradaCercana.value as BusStop;
|
||||||
console.log(`🤖 JARVIS: Parada óptima detectada: ${stopObj.name}`);
|
console.log(`🤖 JARVIS: Parada óptima detectada: ${stopObj.name}`);
|
||||||
|
|
||||||
// Centrar mapa
|
// Ya no centramos o hacemos zoom aquí manual porque la nueva gráfica de updateMapMarkers ajusta bounds y engloba location.
|
||||||
setCenter(stopObj.latitude, stopObj.longitude);
|
|
||||||
setZoom(17);
|
|
||||||
|
|
||||||
// Añadir el PULSO NARANJA
|
// Añadir el PULSO NARANJA
|
||||||
if (optimalStopPulse.value && typeof optimalStopPulse.value.setMap === 'function') {
|
if (optimalStopPulse.value && typeof optimalStopPulse.value.setMap === 'function') {
|
||||||
@ -759,13 +732,6 @@ async function highlightOptimalStopForRoute() {
|
|||||||
{ x: -30, y: -30 }
|
{ 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
|
// Calcular ETAs
|
||||||
await calcularETA(routeStore.selectedRouteId!, stopObj);
|
await calcularETA(routeStore.selectedRouteId!, stopObj);
|
||||||
showETACard.value = true;
|
showETACard.value = true;
|
||||||
@ -837,13 +803,9 @@ function calculateWalkingPath(origin: { lat: number, lng: number }, targetStop:
|
|||||||
const route = dirResult.routes[0];
|
const route = dirResult.routes[0];
|
||||||
const leg = route.legs?.[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) {
|
if (leg) {
|
||||||
navigationInfo.value = {
|
// console.log('Distancia', leg.distance?.text);
|
||||||
distance: leg.distance?.text || '---',
|
|
||||||
duration: leg.duration?.text || '---',
|
|
||||||
targetName: targetStop.name
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (walkingPolyline.value) walkingPolyline.value.setMap(null);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -893,7 +850,7 @@ function clearNavigation() {
|
|||||||
<!-- Main Map Container -->
|
<!-- Main Map Container -->
|
||||||
<div class="map-side">
|
<div class="map-side">
|
||||||
<div class="map-view">
|
<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 || errorRuta" class="status-indicator">
|
||||||
<div v-if="estasCargandoRuta" class="loading-pill">
|
<div v-if="estasCargandoRuta" class="loading-pill">
|
||||||
Calculando ruta real...
|
Calculando ruta real...
|
||||||
@ -903,6 +860,32 @@ function clearNavigation() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="map-container">
|
||||||
<!-- Floating Offers Button at exact location -->
|
<!-- Floating Offers Button at exact location -->
|
||||||
<div v-if="mapsError" class="error">
|
<div v-if="mapsError" class="error">
|
||||||
@ -975,27 +958,6 @@ function clearNavigation() {
|
|||||||
|
|
||||||
</div>
|
</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 -->
|
<!-- Uber-style Search Panel -->
|
||||||
<Transition name="uber-slide">
|
<Transition name="uber-slide">
|
||||||
<div v-if="showUberSearch" class="uber-search-panel" :class="{ 'is-focused': isInputFocused }">
|
<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">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
import { useTaxiStore } from '@/stores/taxi'
|
import { useTaxiStore } from '@/stores/taxi'
|
||||||
import { useShuttleStore } from '@/stores/shuttle'
|
import { useShuttleStore } from '@/stores/shuttle'
|
||||||
@ -23,25 +24,13 @@ const shifts = ['all', 'dia', 'tarde', 'noche']
|
|||||||
// Shuttle Filters
|
// Shuttle Filters
|
||||||
const shuttleRouteFilter = ref('all')
|
const shuttleRouteFilter = ref('all')
|
||||||
const shuttleTypeFilter = ref('all')
|
const shuttleTypeFilter = ref('all')
|
||||||
const expandedShuttleId = ref<string | null>(null)
|
const router = useRouter()
|
||||||
const shuttleRefs = ref<Record<string, any>>({})
|
const shuttleRefs = ref<Record<string, any>>({})
|
||||||
|
|
||||||
const setShuttleRef = (el: any, id: string) => {
|
const setShuttleRef = (el: any, id: string) => {
|
||||||
if (el) shuttleRefs.value[id] = el
|
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 shuttleRoutes = computed(() => {
|
||||||
const routes = shuttleStore.shuttles.map(s => `${s.origin} - ${s.destination}`)
|
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}`
|
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) {
|
function getShiftLabel(shift: string) {
|
||||||
if (shift === 'dia') return t('taxi.dayShift')
|
if (shift === 'dia') return t('taxi.dayShift')
|
||||||
@ -275,26 +247,15 @@ function getShiftLabel(shift: string) {
|
|||||||
|
|
||||||
<div v-else class="shuttles-grid">
|
<div v-else class="shuttles-grid">
|
||||||
<!-- OVERLAY BACKDROP when a card is expanded -->
|
<!-- 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
|
<div
|
||||||
v-for="shuttle in filteredShuttles"
|
v-for="shuttle in filteredShuttles"
|
||||||
:key="shuttle.id"
|
:key="shuttle.id"
|
||||||
v-memo="[shuttle.id]"
|
v-memo="[shuttle.id]"
|
||||||
:ref="el => setShuttleRef(el, shuttle.id)"
|
:ref="el => setShuttleRef(el, shuttle.id)"
|
||||||
class="shuttle-card"
|
class="shuttle-card"
|
||||||
:class="{ expanded: expandedShuttleId === shuttle.id }"
|
|
||||||
@click="() => {
|
@click="() => {
|
||||||
expandedShuttleId = expandedShuttleId === shuttle.id ? null : shuttle.id;
|
analyticsService.logEvent({ event_name: 'shuttle_view_detailed', item_id: shuttle.id });
|
||||||
if (expandedShuttleId === shuttle.id) {
|
router.push(`/shuttle/${shuttle.id}`);
|
||||||
analyticsService.logEvent({ event_name: 'shuttle_view', item_id: shuttle.id });
|
|
||||||
}
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@ -325,72 +286,10 @@ function getShiftLabel(shift: string) {
|
|||||||
{{ shuttle.vehicle_type }}
|
{{ shuttle.vehicle_type }}
|
||||||
</div>
|
</div>
|
||||||
<div class="expand-indicator">
|
<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>
|
</div>
|
||||||
</div> <!-- Close shuttle-main-info -->
|
</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>
|
||||||
|
|
||||||
<div v-if="filteredShuttles.length === 0" class="empty-state">
|
<div v-if="filteredShuttles.length === 0" class="empty-state">
|
||||||
|
|||||||
Reference in New Issue
Block a user