feat: habilitar set completo de APIs de Google Maps y actualizar llaves funcionales para produccion
This commit is contained in:
@ -51,6 +51,11 @@ export const routesService = {
|
||||
/** Update a stop on a route (Admin) - including reorder */
|
||||
async updateRouteStop(routeId: string, stopId: string, data: import('@/types').RouteStopUpdate): Promise<void> {
|
||||
await apiClient.put(`/api/routes/${routeId}/stops/${stopId}`, data)
|
||||
},
|
||||
|
||||
/** Remove a stop from a route (Admin) */
|
||||
async removeStopFromRoute(routeId: string, stopId: string): Promise<void> {
|
||||
await apiClient.delete(`/api/routes/${routeId}/stops/${stopId}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,92 +1,136 @@
|
||||
<template>
|
||||
<div class="admin-routes">
|
||||
<div class="admin-routes" :class="{ 'with-map': selectedRoute }">
|
||||
<div class="header">
|
||||
<button class="back-link" @click="router.push('/admin')">← Volver al Panel</button>
|
||||
<h1>Gestionar Rutas</h1>
|
||||
<button class="add-button" @click="createRoute">
|
||||
<button class="back-link" @click="handleBack">← Volver</button>
|
||||
<h1>Gestión de Rutas</h1>
|
||||
<button v-if="!selectedRoute" class="add-button" @click="createRoute">
|
||||
<span class="material-icons">add</span> Nueva Ruta
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Route List -->
|
||||
<div v-if="!selectedRoute" class="route-list">
|
||||
<div v-for="route in routes" :key="route.id" class="route-card" @click="selectRoute(route)">
|
||||
<div class="route-info">
|
||||
<h3>{{ route.name }}</h3>
|
||||
<p>{{ route.origin_city }} → {{ route.destination_city }}</p>
|
||||
<div class="status" :class="route.status">{{ translateStatus(route.status) }}</div>
|
||||
<div class="main-layout">
|
||||
<!-- MAP SIDE (Visible when editing) -->
|
||||
<div v-if="selectedRoute" class="map-container">
|
||||
<div id="route-map" class="route-map"></div>
|
||||
<div class="map-hint">
|
||||
<span class="material-icons">info</span>
|
||||
Haz clic en el mapa para crear una nueva parada o en una existente para añadirla a la ruta.
|
||||
</div>
|
||||
<span class="material-icons">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Single Route Editor -->
|
||||
<div v-else class="route-editor">
|
||||
<div class="editor-header">
|
||||
<button @click="selectedRoute = null">Cerrar</button>
|
||||
<h2>Editar Ruta: {{ selectedRoute.name }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="route-details-form">
|
||||
<div class="form-group">
|
||||
<label>Velocidad Promedio (km/h)</label>
|
||||
<input v-model.number="selectedRoute.average_speed_kmh" @change="updateRouteDetails" type="number" placeholder="ej. 30">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Estado</label>
|
||||
<select v-model="selectedRoute.status" @change="updateRouteDetails">
|
||||
<option value="active">Activa</option>
|
||||
<option value="inactive">Inactiva</option>
|
||||
<option value="maintenance">Mantenimiento</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stops-section">
|
||||
<h3>Paradas y Horarios</h3>
|
||||
<div class="add-stop">
|
||||
<select v-model="newStopId">
|
||||
<option value="">Selecciona una parada para añadir</option>
|
||||
<option v-for="stop in availableStops" :key="stop.id" :value="stop.id">
|
||||
{{ stop.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button @click="addStop" :disabled="!newStopId">Añadir</button>
|
||||
</div>
|
||||
|
||||
<div class="stops-list-editor">
|
||||
<div class="stops-header">
|
||||
<span>#</span>
|
||||
<span>Nombre</span>
|
||||
<span>Espera (min)</span>
|
||||
<span>Llegada</span>
|
||||
<span>Acciones</span>
|
||||
<!-- CONTENT SIDE -->
|
||||
<div class="content-side">
|
||||
<!-- Route List -->
|
||||
<div v-if="!selectedRoute" class="route-list">
|
||||
<div v-for="route in routes" :key="route.id" class="route-card" @click="selectRoute(route)">
|
||||
<div class="route-info">
|
||||
<h3>{{ route.name }}</h3>
|
||||
<p>{{ route.origin_city }} → {{ route.destination_city }}</p>
|
||||
<div class="status" :class="route.status">{{ translateStatus(route.status) }}</div>
|
||||
</div>
|
||||
<span class="material-icons">chevron_right</span>
|
||||
</div>
|
||||
<!-- We use computed enriched stops for display, but need to bind inputs to original array or handle updates -->
|
||||
<div v-for="(stop, index) in routeStops" :key="stop.id" class="stop-item">
|
||||
<span class="stop-order">{{ index + 1 }}</span>
|
||||
<span class="stop-name">{{ stop.name }}</span>
|
||||
<div v-if="routes.length === 0" class="empty-state">No hay rutas registradas.</div>
|
||||
</div>
|
||||
|
||||
<!-- Single Route Editor -->
|
||||
<div v-else class="route-editor">
|
||||
<div class="editor-header">
|
||||
<h2>Editar: {{ selectedRoute.name }}</h2>
|
||||
<div class="editor-actions">
|
||||
<button class="delete-route-btn" @click="deleteRoute">Eliminar Ruta</button>
|
||||
<button class="close-btn" @click="selectedRoute = null">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-scroll">
|
||||
<section class="form-section">
|
||||
<h3>Información Básica</h3>
|
||||
<div class="route-details-form">
|
||||
<div class="form-group">
|
||||
<label>Nombre de la Ruta</label>
|
||||
<input v-model="selectedRoute.name" @change="updateRouteDetails" type="text" placeholder="Ej: Boquete - David">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Origen</label>
|
||||
<input v-model="selectedRoute.origin_city" @change="updateRouteDetails" type="text" placeholder="David">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Destino</label>
|
||||
<input v-model="selectedRoute.destination_city" @change="updateRouteDetails" type="text" placeholder="Boquete">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Velocidad Promedio (km/h)</label>
|
||||
<input v-model.number="selectedRoute.average_speed_kmh" @change="updateRouteDetails" type="number" placeholder="30">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Estado</label>
|
||||
<select v-model="selectedRoute.status" @change="updateRouteDetails">
|
||||
<option value="active">Activa</option>
|
||||
<option value="inactive">Inactiva</option>
|
||||
<option value="maintenance">Mantenimiento</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color de Línea</label>
|
||||
<input v-model="selectedRoute.color" @change="updateRouteDetails" type="color">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="stop-delay">
|
||||
<input
|
||||
v-model.number="stop.stop_delay_minutes"
|
||||
type="number"
|
||||
min="0"
|
||||
class="delay-input"
|
||||
@change="updateStop(stop)"
|
||||
placeholder="0"
|
||||
>
|
||||
</div>
|
||||
|
||||
<span class="stop-arrival">
|
||||
+{{ Math.round(arrivalTimes[index] || 0) }} min
|
||||
</span>
|
||||
|
||||
<div class="stop-actions">
|
||||
<button @click="moveStop(stop, index, -1)" :disabled="index === 0">↑</button>
|
||||
<button @click="moveStop(stop, index, 1)" :disabled="index === routeStops.length - 1">↓</button>
|
||||
<button @click="removeStop(stop)" class="remove">×</button>
|
||||
</div>
|
||||
<section class="stops-section">
|
||||
<div class="section-header">
|
||||
<h3>Paradas en la Ruta ({{ routeStops.length }})</h3>
|
||||
<button class="save-changes-btn" @click="selectedRoute = null">Guardar Todo</button>
|
||||
</div>
|
||||
|
||||
<div class="add-stop-inline">
|
||||
<select v-model="newStopId">
|
||||
<option value="">Selecciona una parada para añadir...</option>
|
||||
<option v-for="stop in availableStops" :key="stop.id" :value="stop.id">
|
||||
{{ stop.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button @click="addStop" :disabled="!newStopId" class="add-btn">Añadir</button>
|
||||
</div>
|
||||
|
||||
<div class="stops-list-editor">
|
||||
<div v-for="(stop, index) in routeStops" :key="stop.id" class="stop-item">
|
||||
<div class="stop-main">
|
||||
<div class="stop-rank">{{ index + 1 }}</div>
|
||||
<div class="stop-details">
|
||||
<div class="name">{{ stop.name }}</div>
|
||||
<div class="stats">+{{ Math.round(arrivalTimes[index] || 0) }} min</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stop-edit-fields">
|
||||
<div class="input-with-label">
|
||||
<label>Espera (min)</label>
|
||||
<input
|
||||
v-model.number="stop.stop_delay_minutes"
|
||||
type="number"
|
||||
min="0"
|
||||
@change="updateStop(stop)"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stop-controls">
|
||||
<button class="move-btn" @click="moveStop(stop, index, -1)" :disabled="index === 0">
|
||||
<span class="material-icons">expand_less</span>
|
||||
</button>
|
||||
<button class="move-btn" @click="moveStop(stop, index, 1)" :disabled="index === routeStops.length - 1">
|
||||
<span class="material-icons">expand_more</span>
|
||||
</button>
|
||||
<button class="remove-btn" @click="removeStop(stop)">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="routeStops.length === 0" class="no-stops">
|
||||
No hay paradas en esta ruta. Usa el mapa o el buscador superior.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -95,23 +139,31 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
import { routesService } from '@/services/routesService'
|
||||
import { busStopsService } from '@/services/busStopsService'
|
||||
import { useGoogleMaps } from '@/composables/useGoogleMaps'
|
||||
import type { Route, BusStop } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const routes = ref<Route[]>([])
|
||||
const allStops = ref<BusStop[]>([])
|
||||
const selectedRoute = ref<Route | null>(null)
|
||||
const routeStops = ref<BusStop[]>([])
|
||||
const newStopId = ref('')
|
||||
|
||||
// Map integration
|
||||
const { initMap, addNumberedMarker, addMarker, addPolyline, clearAllOverlays, isLoaded: mapsLoaded, map: gmap } = useGoogleMaps()
|
||||
|
||||
onMounted(async () => {
|
||||
await loadInitialData()
|
||||
})
|
||||
|
||||
async function loadInitialData() {
|
||||
routes.value = await routesService.getAllRoutes()
|
||||
allStops.value = await busStopsService.getAllBusStops()
|
||||
})
|
||||
}
|
||||
|
||||
const availableStops = computed(() => {
|
||||
const currentIds = new Set(routeStops.value.map((s: BusStop) => s.id))
|
||||
@ -120,473 +172,349 @@ const availableStops = computed(() => {
|
||||
|
||||
const arrivalTimes = computed(() => {
|
||||
if (!selectedRoute.value || !routeStops.value.length) return []
|
||||
|
||||
// speed is in km/h. Convert to km/min = speed / 60
|
||||
const speed = selectedRoute.value.average_speed_kmh || 30 // default 30km/h
|
||||
const speed = selectedRoute.value.average_speed_kmh || 30
|
||||
const speedKmPerMin = speed / 60
|
||||
|
||||
const times: number[] = []
|
||||
let currentTime = 0 // minutes from start
|
||||
|
||||
// First stop is at 0
|
||||
let currentTime = 0
|
||||
times.push(0)
|
||||
|
||||
for (let i = 1; i < routeStops.value.length; i++) {
|
||||
const prev = routeStops.value[i-1]
|
||||
const curr = routeStops.value[i]
|
||||
|
||||
if (!prev || !curr) continue
|
||||
|
||||
// Add delay of previous stop
|
||||
if (!prev || !curr) continue;
|
||||
currentTime += (prev.stop_delay_minutes || 0)
|
||||
|
||||
// Calculate travel time
|
||||
const dist = haversineDistance(prev.latitude, prev.longitude, curr.latitude, curr.longitude)
|
||||
const travelTime = dist / speedKmPerMin
|
||||
|
||||
currentTime += travelTime
|
||||
const dist = calculateDistance(prev.latitude, prev.longitude, curr.latitude, curr.longitude)
|
||||
currentTime += (dist / speedKmPerMin)
|
||||
times.push(currentTime)
|
||||
}
|
||||
|
||||
return times
|
||||
})
|
||||
|
||||
// Haversine formula for distance in km
|
||||
function haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
|
||||
const R = 6371; // Radius of the earth in km
|
||||
const dLat = deg2rad(lat2 - lat1);
|
||||
const dLon = deg2rad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat/2) * Math.sin(dLat/2) +
|
||||
Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) *
|
||||
Math.sin(dLon/2) * Math.sin(dLon/2)
|
||||
;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
const d = R * c; // Distance in km
|
||||
return d;
|
||||
}
|
||||
|
||||
function deg2rad(deg: number) {
|
||||
return deg * (Math.PI/180)
|
||||
}
|
||||
|
||||
function translateStatus(status: string) {
|
||||
const statuses: Record<string, string> = {
|
||||
'active': 'Activa',
|
||||
'inactive': 'Inactiva',
|
||||
'maintenance': 'Mantenimiento'
|
||||
// Watchers for Map Rendering
|
||||
watch([selectedRoute, mapsLoaded], ([route, loaded]) => {
|
||||
if (route && loaded) {
|
||||
nextTick(() => {
|
||||
initRouteMap()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Update map when stops change
|
||||
watch(routeStops, () => {
|
||||
if (selectedRoute.value) updateMapOverlays()
|
||||
}, { deep: true })
|
||||
|
||||
async function initRouteMap() {
|
||||
initMap('route-map', { lat: 8.4284, lng: -82.4309 }, 13)
|
||||
|
||||
// Add Click listener to Create New Stops
|
||||
if (gmap.value) {
|
||||
gmap.value.addListener('click', async (e: any) => {
|
||||
const lat = e.latLng.lat()
|
||||
const lng = e.latLng.lng()
|
||||
|
||||
const name = prompt('Crear nueva parada en este punto? Introduce el nombre:')
|
||||
if (name) {
|
||||
try {
|
||||
const newStop = await busStopsService.createBusStop({
|
||||
name,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
stop_type: 'regular',
|
||||
has_shelter: false,
|
||||
has_seating: false,
|
||||
is_accessible: true,
|
||||
city: selectedRoute.value?.origin_city || 'David'
|
||||
})
|
||||
// Refetch all stops
|
||||
allStops.value = await busStopsService.getAllBusStops()
|
||||
// Add to current route
|
||||
await addExistingStop(newStop.id)
|
||||
} catch (err) {
|
||||
alert('Error creando parada')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
updateMapOverlays()
|
||||
}
|
||||
|
||||
function updateMapOverlays() {
|
||||
clearAllOverlays()
|
||||
|
||||
// 1. Draw Polyline
|
||||
if (routeStops.value.length > 1) {
|
||||
const path = routeStops.value.map(s => ({ lat: s.latitude, lng: s.longitude }))
|
||||
addPolyline(path)
|
||||
}
|
||||
|
||||
// 2. Add Route Markers (Yellow Numbered)
|
||||
routeStops.value.forEach((stop, index) => {
|
||||
addNumberedMarker(
|
||||
{ lat: stop.latitude, lng: stop.longitude },
|
||||
index + 1,
|
||||
stop.name
|
||||
)
|
||||
})
|
||||
|
||||
// 3. Add Available Markers (Small Gray Dots)
|
||||
availableStops.value.forEach(stop => {
|
||||
const marker = addMarker(
|
||||
{ lat: stop.latitude, lng: stop.longitude },
|
||||
{
|
||||
title: stop.name,
|
||||
icon: {
|
||||
path: google.maps.SymbolPath.CIRCLE,
|
||||
scale: 6,
|
||||
fillColor: '#666',
|
||||
fillOpacity: 0.8,
|
||||
strokeWeight: 1,
|
||||
strokeColor: '#fff'
|
||||
}
|
||||
}
|
||||
)
|
||||
if (marker) {
|
||||
marker.addListener('click', () => {
|
||||
addExistingStop(stop.id)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Actions
|
||||
function handleBack() {
|
||||
if (selectedRoute.value) {
|
||||
selectedRoute.value = null
|
||||
} else {
|
||||
router.push('/admin')
|
||||
}
|
||||
return statuses[status] || status
|
||||
}
|
||||
|
||||
async function createRoute() {
|
||||
const name = prompt("Introduce el nombre de la ruta")
|
||||
if (name) {
|
||||
await routesService.createRoute({
|
||||
name,
|
||||
origin_city: 'David',
|
||||
destination_city: 'Boquete',
|
||||
status: 'active',
|
||||
color: '#000000',
|
||||
direction: 'outbound'
|
||||
})
|
||||
routes.value = await routesService.getAllRoutes()
|
||||
}
|
||||
const name = prompt("Nombre de la nueva ruta:")
|
||||
if (!name) return
|
||||
const route = await routesService.createRoute({
|
||||
name,
|
||||
origin_city: 'David',
|
||||
destination_city: 'Boquete',
|
||||
status: 'active',
|
||||
color: '#FEE715',
|
||||
direction: 'outbound'
|
||||
})
|
||||
routes.value = await routesService.getAllRoutes()
|
||||
selectRoute(route)
|
||||
}
|
||||
|
||||
async function selectRoute(route: Route) {
|
||||
selectedRoute.value = route
|
||||
// Ensure route object is reactive for editing (ref is reactive, passing obj by ref is fine)
|
||||
routeStops.value = await routesService.getRouteStops(route.id)
|
||||
}
|
||||
|
||||
async function updateRouteDetails() {
|
||||
if (!selectedRoute.value) return
|
||||
try {
|
||||
await routesService.updateRoute(selectedRoute.value.id, {
|
||||
average_speed_kmh: selectedRoute.value.average_speed_kmh,
|
||||
status: selectedRoute.value.status
|
||||
})
|
||||
} catch (e) {
|
||||
alert('Error al actualizar la ruta')
|
||||
await routesService.updateRoute(selectedRoute.value.id, {
|
||||
name: selectedRoute.value.name,
|
||||
origin_city: selectedRoute.value.origin_city,
|
||||
destination_city: selectedRoute.value.destination_city,
|
||||
average_speed_kmh: selectedRoute.value.average_speed_kmh,
|
||||
status: selectedRoute.value.status,
|
||||
color: selectedRoute.value.color
|
||||
})
|
||||
}
|
||||
|
||||
async function addStop() {
|
||||
if (newStopId.value) {
|
||||
await addExistingStop(newStopId.value)
|
||||
newStopId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function addExistingStop(stopId: string) {
|
||||
if (!selectedRoute.value) return
|
||||
await routesService.addStopToRoute(selectedRoute.value.id, {
|
||||
stop_id: stopId,
|
||||
stop_order: routeStops.value.length + 1
|
||||
})
|
||||
routeStops.value = await routesService.getRouteStops(selectedRoute.value.id)
|
||||
}
|
||||
|
||||
async function updateStop(stop: BusStop) {
|
||||
if (!selectedRoute.value) return
|
||||
try {
|
||||
await routesService.updateRouteStop(selectedRoute.value.id, stop.id, {
|
||||
stop_delay_minutes: stop.stop_delay_minutes || 0
|
||||
})
|
||||
} catch (e) {
|
||||
alert('Error al actualizar el retraso de la parada')
|
||||
}
|
||||
}
|
||||
|
||||
async function addStop() {
|
||||
if (!selectedRoute.value || !newStopId.value) return
|
||||
try {
|
||||
await routesService.addStopToRoute(selectedRoute.value.id, {
|
||||
stop_id: newStopId.value,
|
||||
stop_order: routeStops.value.length + 1
|
||||
})
|
||||
routeStops.value = await routesService.getRouteStops(selectedRoute.value.id)
|
||||
newStopId.value = ''
|
||||
} catch (e) {
|
||||
alert('Error al añadir parada')
|
||||
}
|
||||
await routesService.updateRouteStop(selectedRoute.value.id, stop.id, {
|
||||
stop_delay_minutes: stop.stop_delay_minutes || 0
|
||||
})
|
||||
}
|
||||
|
||||
async function moveStop(stop: BusStop, index: number, direction: number) {
|
||||
if (!selectedRoute.value) return
|
||||
const newOrder = index + 1 + direction
|
||||
try {
|
||||
await routesService.updateRouteStop(selectedRoute.value.id, stop.id, {
|
||||
stop_order: newOrder
|
||||
})
|
||||
await routesService.updateRouteStop(selectedRoute.value.id, stop.id, {
|
||||
stop_order: index + 1 + direction
|
||||
})
|
||||
routeStops.value = await routesService.getRouteStops(selectedRoute.value.id)
|
||||
}
|
||||
|
||||
async function removeStop(stop: BusStop) {
|
||||
if (!selectedRoute.value) return
|
||||
if (confirm(`Borrar ${stop.name} de la ruta?`)) {
|
||||
await routesService.removeStopFromRoute(selectedRoute.value.id, stop.id)
|
||||
routeStops.value = await routesService.getRouteStops(selectedRoute.value.id)
|
||||
} catch (e) {
|
||||
alert('Error al mover parada')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeStop(_stop: BusStop) {
|
||||
alert('Borrar no implementado aún')
|
||||
async function deleteRoute() {
|
||||
if (!selectedRoute.value) return
|
||||
if (confirm(`Estás SEGURO de que quieres eliminar la ruta ${selectedRoute.value.name}? Esta acción es permanente.`)) {
|
||||
await routesService.deleteRoute(selectedRoute.value.id)
|
||||
selectedRoute.value = null
|
||||
routes.value = await routesService.getAllRoutes()
|
||||
}
|
||||
}
|
||||
|
||||
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
|
||||
const R = 6371
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180
|
||||
const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2)
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))
|
||||
}
|
||||
|
||||
function translateStatus(s: string) {
|
||||
return { active: 'Activa', inactive: 'Inactiva', maintenance: 'Mantenimiento' }[s] || s
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-routes {
|
||||
padding: 32px 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
background: #0f172a;
|
||||
min-height: 100vh;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 48px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
background: var(--hover-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 12px 20px;
|
||||
border-radius: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: var(--active-bg);
|
||||
border-color: var(--active-color);
|
||||
color: var(--active-color);
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||
font-weight: 900;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: -0.04em;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
|
||||
color: #101820;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 14px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 10px 20px rgba(254, 231, 21, 0.15);
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 15px 30px rgba(254, 231, 21, 0.25);
|
||||
}
|
||||
|
||||
.add-button .material-icons {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Route List */
|
||||
.route-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.route-card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.route-card:hover {
|
||||
transform: translateY(-8px);
|
||||
border-color: var(--active-color);
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.route-info h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 900;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.route-info p {
|
||||
margin: 0 0 16px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
padding: 6px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.status.active { background: rgba(34, 197, 94, 0.1); color: #4ade80; border: 1px solid rgba(34, 197, 94, 0.2); }
|
||||
.status.inactive { background: rgba(239, 68, 68, 0.1); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.2); }
|
||||
.status.maintenance { background: rgba(245, 158, 11, 0.1); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.2); }
|
||||
|
||||
.route-card .material-icons {
|
||||
color: var(--text-secondary);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.route-card:hover .material-icons {
|
||||
color: var(--active-color);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Editor Styles */
|
||||
.route-editor {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
border-radius: 32px;
|
||||
padding: 40px;
|
||||
border: 1px solid var(--border-color);
|
||||
animation: slideIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.editor-header h2 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 900;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editor-header button {
|
||||
background: var(--hover-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 20px;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.editor-header button:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #f87171;
|
||||
border-color: #f87171;
|
||||
}
|
||||
|
||||
.route-details-form {
|
||||
background: var(--bg-secondary);
|
||||
padding: 24px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 40px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
color: var(--active-color);
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-group input, .form-group select {
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
outline: none;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus, .form-group select:focus {
|
||||
border-color: var(--active-color);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.stops-section h3 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 900;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.add-stop {
|
||||
h1 { font-size: 1.5rem; font-weight: 800; color: #FEE715; margin: 0; }
|
||||
|
||||
.back-link {
|
||||
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
|
||||
color: white; padding: 8px 16px; border-radius: 8px; cursor: pointer;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
background: #FEE715; color: #101820; border: none; padding: 10px 20px;
|
||||
border-radius: 10px; font-weight: 800; cursor: pointer; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
gap: 24px;
|
||||
height: calc(100vh - 100px);
|
||||
}
|
||||
|
||||
.add-stop select { flex: 1; }
|
||||
|
||||
.add-stop button {
|
||||
background: var(--active-color);
|
||||
color: #101820;
|
||||
border: none;
|
||||
padding: 0 24px;
|
||||
border-radius: 14px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
.map-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.add-stop button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.route-map { width: 100%; height: 100%; }
|
||||
|
||||
.stops-list-editor {
|
||||
.map-hint {
|
||||
position: absolute; bottom: 16px; left: 16px; right: 16px;
|
||||
background: rgba(0,0,0,0.8); backdrop-filter: blur(8px);
|
||||
padding: 12px; border-radius: 12px; color: #94a3b8; font-size: 0.8rem;
|
||||
display: flex; align-items: center; gap: 8px; border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.content-side {
|
||||
width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stops-header {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 120px 120px 150px;
|
||||
padding: 12px 24px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 800;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
.with-map .content-side { width: 45%; }
|
||||
@media (max-width: 1000px) {
|
||||
.main-layout { flex-direction: column; height: auto; }
|
||||
.content-side { width: 100% !important; }
|
||||
.map-container { height: 400px; order: -1; }
|
||||
}
|
||||
|
||||
/* Route List */
|
||||
.route-list { display: flex; flex-direction: column; gap: 12px; height: 100%; }
|
||||
.route-card {
|
||||
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.1);
|
||||
padding: 16px; border-radius: 16px; cursor: pointer; display: flex;
|
||||
justify-content: space-between; align-items: center; transition: all 0.2s;
|
||||
}
|
||||
.route-card:hover { border-color: #FEE715; background: rgba(254,231,21,0.05); }
|
||||
.route-info h3 { margin: 0; font-size: 1.1rem; }
|
||||
.route-info p { margin: 4px 0 10px; color: #94a3b8; font-size: 0.85rem; }
|
||||
.status { font-size: 0.7rem; font-weight: 800; padding: 4px 8px; border-radius: 6px; width: fit-content; text-transform: uppercase; }
|
||||
.status.active { background: rgba(34, 197, 94, 0.1); color: #4ade80; }
|
||||
.status.inactive { background: rgba(239, 68, 68, 0.1); color: #f87171; }
|
||||
|
||||
/* Editor View */
|
||||
.route-editor {
|
||||
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 20px; height: 100%; display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-header { padding: 20px; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||||
.editor-header h2 { margin: 0 0 12px; font-size: 1.25rem; font-weight: 800; }
|
||||
.editor-actions { display: flex; gap: 10px; }
|
||||
.close-btn { background: #334155; color: white; border: none; padding: 6px 16px; border-radius: 8px; cursor: pointer; }
|
||||
.delete-route-btn { background: rgba(239, 68, 68, 0.1); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.2); padding: 6px 16px; border-radius: 8px; cursor: pointer; }
|
||||
|
||||
.editor-scroll { flex: 1; overflow-y: auto; padding: 20px; }
|
||||
|
||||
.form-section { margin-bottom: 32px; }
|
||||
.form-section h3 { font-size: 0.9rem; text-transform: uppercase; color: #FEE715; margin-bottom: 16px; letter-spacing: 0.05em; }
|
||||
|
||||
.route-details-form { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-group label { font-size: 0.75rem; color: #94a3b8; font-weight: 700; }
|
||||
.form-group input, .form-group select {
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 10px; color: white; font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.save-changes-btn { background: #FEE715; color: #101820; border: none; padding: 8px 16px; border-radius: 8px; font-weight: 800; cursor: pointer; }
|
||||
|
||||
.add-stop-inline { display: flex; gap: 8px; margin-bottom: 20px; }
|
||||
.add-stop-inline select { flex: 1; background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 10px; color: white; }
|
||||
.add-btn { background: #334155; color: white; border: none; padding: 0 16px; border-radius: 8px; cursor: pointer; }
|
||||
|
||||
.stops-list-editor { display: flex; flex-direction: column; gap: 8px; }
|
||||
.stop-item {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 120px 120px 150px;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 20px;
|
||||
gap: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s;
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 12px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
}
|
||||
.stop-main { display: flex; align-items: center; gap: 12px; flex: 1; }
|
||||
.stop-rank { width: 28px; height: 28px; background: #FEE715; color: #101820; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 900; font-size: 0.8rem; }
|
||||
.stop-details .name { font-weight: 700; font-size: 0.9rem; }
|
||||
.stop-details .stats { font-size: 0.75rem; color: #94a3b8; margin-top: 2px; }
|
||||
|
||||
.stop-item:hover {
|
||||
border-color: var(--active-color);
|
||||
transform: translateX(8px);
|
||||
.stop-edit-fields { display: flex; gap: 12px; }
|
||||
.input-with-label { display: flex; flex-direction: column; gap: 2px; }
|
||||
.input-with-label label { font-size: 0.6rem; color: #64748b; text-transform: uppercase; font-weight: 800; }
|
||||
.input-with-label input { width: 60px; background: #0f172a; border: 1px solid #334155; padding: 4px 6px; border-radius: 4px; color: white; text-align: center; }
|
||||
|
||||
.stop-controls { display: flex; gap: 4px; }
|
||||
.move-btn, .remove-btn {
|
||||
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 6px; cursor: pointer; border: none; color: #94a3b8; background: transparent; transition: all 0.2s;
|
||||
}
|
||||
.move-btn:hover:not(:disabled) { background: rgba(255,255,255,0.05); color: #FEE715; }
|
||||
.remove-btn:hover { background: rgba(239, 68, 68, 0.1); color: #f87171; }
|
||||
.move-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
.stop-order { font-weight: 900; color: var(--active-color); font-size: 1.1rem; }
|
||||
.stop-name { font-weight: 700; color: var(--text-primary); }
|
||||
.stop-arrival { font-weight: 800; color: var(--text-secondary); }
|
||||
|
||||
.delay-input {
|
||||
width: 80px !important;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.stop-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stop-actions button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stop-actions button:hover:not(:disabled) {
|
||||
background: var(--active-color);
|
||||
color: #101820;
|
||||
border-color: var(--active-color);
|
||||
}
|
||||
|
||||
.stop-actions button.remove:hover {
|
||||
background: #f87171;
|
||||
border-color: #f87171;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.header { flex-direction: column; align-items: stretch; text-align: center; }
|
||||
.back-link { justify-content: center; }
|
||||
.add-button { justify-content: center; }
|
||||
.route-details-form { grid-template-columns: 1fr; }
|
||||
.stops-header { display: none; }
|
||||
.stop-item { grid-template-columns: 1fr; text-align: center; justify-items: center; }
|
||||
.stop-actions { margin-top: 12px; }
|
||||
}
|
||||
.no-stops { text-align: center; padding: 40px; color: #64748b; font-style: italic; border: 1px dashed #334155; border-radius: 12px; }
|
||||
.empty-state { text-align: center; padding: 40px; color: #64748b; }
|
||||
</style>
|
||||
|
||||
@ -39,6 +39,12 @@ onMounted(async () => {
|
||||
<div class="auth-glow" aria-hidden="true"></div>
|
||||
|
||||
<div class="auth-wrapper">
|
||||
<!-- Botón volver al mapa -->
|
||||
<button class="back-to-map" @click="router.push('/map')">
|
||||
<span class="material-icons">arrow_back</span>
|
||||
Volver al mapa
|
||||
</button>
|
||||
|
||||
<!-- Branding -->
|
||||
<div class="auth-brand">
|
||||
<h1 class="brand-title">SIBU</h1>
|
||||
@ -191,6 +197,34 @@ onMounted(async () => {
|
||||
transform: translateX(-16px);
|
||||
}
|
||||
|
||||
/* ─── Botón volver ─── */
|
||||
.back-to-map {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
padding: 10px 16px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.back-to-map:hover {
|
||||
color: var(--active-color);
|
||||
border-color: var(--active-color);
|
||||
background: rgba(254, 231, 21, 0.06);
|
||||
}
|
||||
|
||||
.back-to-map .material-icons {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ─── Footer ─── */
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
|
||||
@ -251,12 +251,21 @@ function getShiftLabel(shift: string) {
|
||||
</div>
|
||||
|
||||
<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"
|
||||
class="shuttle-card"
|
||||
:class="{ expanded: expandedShuttleId === shuttle.id }"
|
||||
:style="{ backgroundImage: `url(${shuttle.image_url || 'https://images.unsplash.com/photo-1449034446853-66c86144b0ad?auto=format&fit=crop&q=80&w=2070'})` }"
|
||||
:style="{ backgroundImage: `url(${shuttle.image_url || 'https://images.unsplash.com/photo-1544620347-c4fd4a3d5957?auto=format&fit=crop&q=80&w=2069'})` }"
|
||||
@click="() => {
|
||||
expandedShuttleId = expandedShuttleId === shuttle.id ? null : shuttle.id;
|
||||
if (expandedShuttleId === shuttle.id) {
|
||||
@ -264,6 +273,7 @@ function getShiftLabel(shift: string) {
|
||||
}
|
||||
}"
|
||||
>
|
||||
<!-- Collapsed info (always visible) -->
|
||||
<div class="shuttle-main-info">
|
||||
<div class="shuttle-header-mini">
|
||||
<div class="company-badge" v-if="shuttle.company_name">
|
||||
@ -273,12 +283,13 @@ function getShiftLabel(shift: string) {
|
||||
<div class="price-pill">
|
||||
<span class="currency">$</span>
|
||||
<span class="amount">{{ shuttle.price_per_person }}</span>
|
||||
<span class="price-pill-label">/p</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shuttle-route-compact" v-if="shuttle.origin && shuttle.destination">
|
||||
<span class="route-text">{{ shuttle.origin }}</span>
|
||||
<span class="material-icons">east</span>
|
||||
<span class="material-icons route-arrow">east</span>
|
||||
<span class="route-text">{{ shuttle.destination }}</span>
|
||||
</div>
|
||||
|
||||
@ -293,8 +304,10 @@ function getShiftLabel(shift: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EXPANDABLE CONTENT -->
|
||||
<div class="shuttle-details" v-if="expandedShuttleId === shuttle.id">
|
||||
<!-- 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>
|
||||
@ -310,41 +323,54 @@ function getShiftLabel(shift: string) {
|
||||
<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>
|
||||
|
||||
<div class="shuttle-footer">
|
||||
<div class="price-box">
|
||||
<div class="price-main">
|
||||
<span class="currency">$</span>
|
||||
<span class="amount">{{ shuttle.price_per_person }}</span>
|
||||
<span class="suffix">{{ t('shuttle.perPerson') }}</span>
|
||||
</div>
|
||||
<div class="lang-badge" v-if="shuttle.english_speaking">
|
||||
<span class="material-icons">g_translate</span>
|
||||
BILINGUAL
|
||||
</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="contact-hub">
|
||||
<a
|
||||
v-if="shuttle.phone_number"
|
||||
:href="'tel:' + shuttle.phone_number"
|
||||
class="mini-btn phone"
|
||||
@click.stop="handleCallShuttle(shuttle)"
|
||||
>
|
||||
<span class="material-icons">phone</span>
|
||||
</a>
|
||||
<button class="mini-btn wa" @click.stop="handleReserve(shuttle)">
|
||||
<span class="material-icons">chat</span>
|
||||
</button>
|
||||
<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">
|
||||
<span class="material-icons">directions_bus_filled</span>
|
||||
<p>{{ t('shuttle.noShuttles') }}</p>
|
||||
<div v-if="filteredShuttles.length === 0" class="empty-state">
|
||||
<span class="material-icons">directions_bus_filled</span>
|
||||
<p>{{ t('shuttle.noShuttles') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@ -406,75 +432,180 @@ function getShiftLabel(shift: string) {
|
||||
box-shadow: 0 4px 15px rgba(254, 231, 21, 0.4);
|
||||
}
|
||||
|
||||
/* Shuttles Grid & Compact Cards */
|
||||
/* =============================================
|
||||
SHUTTLE CARDS — DISEÑO PREMIUM CON FOTO
|
||||
============================================= */
|
||||
|
||||
/* Backdrop modal al expandir */
|
||||
.shuttle-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
z-index: 50;
|
||||
animation: fadeIn 0.25s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Grid de cards */
|
||||
.shuttles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 20px 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ---- La tarjeta base ---- */
|
||||
.shuttle-card {
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
min-height: 170px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Overlay oscuro base (compacto) */
|
||||
.shuttle-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.93) 0%,
|
||||
rgba(0, 0, 0, 0.65) 55%,
|
||||
rgba(0, 0, 0, 0.30) 100%
|
||||
);
|
||||
z-index: 0;
|
||||
transition: background 0.4s ease;
|
||||
}
|
||||
|
||||
/* Cuando está expandida, reforzar el overlay inferior */
|
||||
.shuttle-card.expanded {
|
||||
border-color: var(--active-color);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
border-color: #FEE715;
|
||||
box-shadow: 0 0 0 2px #FEE715, 0 24px 50px rgba(0, 0, 0, 0.7);
|
||||
z-index: 60;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.shuttle-card.expanded::before {
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.97) 0%,
|
||||
rgba(0, 0, 0, 0.80) 45%,
|
||||
rgba(0, 0, 0, 0.40) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Todos los hijos van sobre el overlay */
|
||||
.shuttle-main-info,
|
||||
.shuttle-details {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.shuttle-main-info {
|
||||
padding: 0; /* Ya manejado por el contenedor superior */
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
/* Fila superior: badge empresa + precio */
|
||||
.shuttle-header-mini {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.company-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
background: rgba(0, 0, 0, 0.60);
|
||||
color: #FEE715;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
padding: 5px 11px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
border: 1px solid rgba(254, 231, 21, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.5);
|
||||
border: 1px solid rgba(254, 231, 21, 0.45);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.8);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.company-badge .material-icons {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.price-pill {
|
||||
background: var(--active-color);
|
||||
background: #FEE715;
|
||||
color: #101820;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
padding: 5px 11px;
|
||||
border-radius: 10px;
|
||||
font-weight: 900;
|
||||
font-size: 0.9rem;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
gap: 1px;
|
||||
box-shadow: 0 4px 12px rgba(254, 231, 21, 0.35);
|
||||
}
|
||||
|
||||
.price-pill .currency {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.price-pill .amount {
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.price-pill-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
margin-left: 1px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Ruta origen → destino */
|
||||
.shuttle-route-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
/* Siempre blanco — se lee sobre el overlay oscuro de la imagen */
|
||||
font-size: 1.1rem;
|
||||
font-weight: 900;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 1px 6px rgba(0,0,0,0.7);
|
||||
margin-bottom: 12px;
|
||||
text-shadow: 0 2px 8px rgba(0,0,0,0.9), 0 0 20px rgba(0,0,0,0.6);
|
||||
margin-bottom: 14px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.shuttle-route-compact .material-icons {
|
||||
.route-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.route-arrow {
|
||||
color: #FEE715;
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 0 4px rgba(254,231,21,0.6));
|
||||
}
|
||||
|
||||
/* Fila inferior: tipo vehículo + expand arrow */
|
||||
.shuttle-tags {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@ -482,44 +613,58 @@ function getShiftLabel(shift: string) {
|
||||
}
|
||||
|
||||
.vehicle-tag-mini {
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
padding: 5px 10px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
border-radius: 8px;
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
/* Blanco para contraste sobre imagen */
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.20);
|
||||
}
|
||||
|
||||
.vehicle-tag-mini .material-icons {
|
||||
font-size: 15px;
|
||||
color: #FEE715;
|
||||
}
|
||||
|
||||
.expand-indicator {
|
||||
color: var(--active-color);
|
||||
color: #FEE715;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
filter: drop-shadow(0 0 4px rgba(254,231,21,0.5));
|
||||
}
|
||||
|
||||
/* Expanded content */
|
||||
/* =============================================
|
||||
CONTENIDO EXPANDIDO
|
||||
============================================= */
|
||||
.shuttle-details {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
padding-top: 0;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Separador dashed */
|
||||
.shuttle-separator {
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, rgba(255,255,255,0.25), transparent);
|
||||
margin: 0 20px 16px;
|
||||
}
|
||||
|
||||
.shuttle-body {
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@ -530,108 +675,140 @@ function getShiftLabel(shift: string) {
|
||||
}
|
||||
|
||||
.info-row .material-icons {
|
||||
color: var(--active-color);
|
||||
font-size: 20px;
|
||||
color: #FEE715;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 0 4px rgba(254,231,21,0.4));
|
||||
}
|
||||
|
||||
.info-row .label {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
font-size: 0.65rem;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.info-row .value {
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.95rem;
|
||||
color: #ffffff;
|
||||
margin: 2px 0 0;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||
font-weight: 700;
|
||||
text-shadow: 0 1px 6px rgba(0,0,0,0.9);
|
||||
}
|
||||
|
||||
.shuttle-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end; /* Alineados a la base */
|
||||
margin-top: auto;
|
||||
padding-top: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.price-box {
|
||||
/* ---- Bloque de precios ---- */
|
||||
.price-block {
|
||||
padding: 14px 20px;
|
||||
background: rgba(0, 0, 0, 0.40);
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.price-main {
|
||||
color: var(--active-color);
|
||||
.price-row-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.price-main .currency {
|
||||
font-size: 0.9rem;
|
||||
.price-amount-big {
|
||||
font-size: 2rem;
|
||||
font-weight: 900;
|
||||
color: #FEE715;
|
||||
text-shadow: 0 0 20px rgba(254,231,21,0.4), 0 2px 8px rgba(0,0,0,0.9);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.price-main .amount {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 900;
|
||||
.price-label-big {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255,255,255,0.75);
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.price-main .suffix {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 4px;
|
||||
.price-row-secondary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.price-sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
.price-icon-secondary {
|
||||
font-size: 16px;
|
||||
color: rgba(255,255,255,0.55);
|
||||
}
|
||||
|
||||
.price-amount-secondary {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: rgba(255,255,255,0.85);
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.price-label-secondary {
|
||||
font-size: 0.78rem;
|
||||
color: rgba(255,255,255,0.55);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contact-hub {
|
||||
/* ---- Botones de contacto ---- */
|
||||
.contact-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto; /* Empuja al máximo a la derecha */
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.mini-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
.contact-btn {
|
||||
flex: 1;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
gap: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.mini-btn.phone { background: rgba(255,255,255,0.1); color: white; border: 1px solid rgba(255,255,255,0.2); }
|
||||
.mini-btn.wa { background: #25d366; color: white; box-shadow: 0 4px 12px rgba(37, 211, 102, 0.2); }
|
||||
|
||||
.mini-btn:hover { transform: translateY(-3px); }
|
||||
|
||||
.lang-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(254, 231, 21, 0.2);
|
||||
color: var(--active-color);
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 800;
|
||||
margin-top: 4px;
|
||||
width: fit-content;
|
||||
.btn-call {
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
color: #ffffff;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.30);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.lang-badge .material-icons { font-size: 10px; }
|
||||
.btn-call:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-color: rgba(255, 255, 255, 0.50);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-call .material-icons { font-size: 20px; }
|
||||
|
||||
.btn-whatsapp {
|
||||
background: #25d366;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 6px 20px rgba(37, 211, 102, 0.35);
|
||||
}
|
||||
|
||||
.btn-whatsapp:hover {
|
||||
background: #1ebe5a;
|
||||
box-shadow: 0 8px 24px rgba(37, 211, 102, 0.50);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-whatsapp .material-icons { font-size: 20px; }
|
||||
|
||||
/* Original Styles */
|
||||
.taxi-view {
|
||||
|
||||
Reference in New Issue
Block a user