Initial commit: SIBU 2.0 MISSION
This commit is contained in:
253
frontend/src/views/AdminBusStops.vue
Normal file
253
frontend/src/views/AdminBusStops.vue
Normal file
@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div class="admin-bus-stops">
|
||||
<div class="header">
|
||||
<button class="back-link" @click="$router.push('/admin')">← Volver al Panel</button>
|
||||
<h1>Gestionar Paradas</h1>
|
||||
<button class="add-button" @click="openCreate">
|
||||
<span class="material-icons">add</span> Nueva Parada
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading">Cargando paradas...</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<div v-else class="stops-list">
|
||||
<div v-for="stop in stops" :key="stop.id" class="stop-card">
|
||||
<div class="stop-info">
|
||||
<h3>{{ stop.name }}</h3>
|
||||
<p>{{ stop.city }} - {{ translateType(stop.stop_type) }}</p>
|
||||
<div class="badges">
|
||||
<span v-if="stop.has_shelter" class="badge">Con Techo</span>
|
||||
<span v-if="stop.has_seating" class="badge">Asientos</span>
|
||||
<span v-if="stop.is_accessible" class="badge">Accesible</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stop-actions">
|
||||
<button class="icon-btn edit" @click="openEdit(stop)">
|
||||
<span class="material-icons">edit</span>
|
||||
</button>
|
||||
<button class="icon-btn delete" @click="confirmDelete(stop)">
|
||||
<span class="material-icons">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor Modal -->
|
||||
<div v-if="showEditor" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<BusStopEditor
|
||||
:initial-stop="selectedStop"
|
||||
@save="handleSave"
|
||||
@cancel="closeEditor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { busStopsService } from '@/services/busStopsService'
|
||||
import type { BusStop } from '@/types'
|
||||
import BusStopEditor from '@/components/BusStopEditor.vue'
|
||||
|
||||
const stops = ref<BusStop[]>([])
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const showEditor = ref(false)
|
||||
const selectedStop = ref<BusStop | null>(null)
|
||||
|
||||
onMounted(loadStops)
|
||||
|
||||
async function loadStops() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
stops.value = await busStopsService.getAllBusStops()
|
||||
} catch (e) {
|
||||
error.value = 'Error al cargar las paradas'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function translateType(type: string) {
|
||||
const types: Record<string, string> = {
|
||||
'regular': 'Regular',
|
||||
'terminal': 'Terminal',
|
||||
'express_only': 'Solo Expreso'
|
||||
}
|
||||
return types[type] || type
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedStop.value = null
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
function openEdit(stop: BusStop) {
|
||||
selectedStop.value = stop
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
showEditor.value = false
|
||||
selectedStop.value = null
|
||||
}
|
||||
|
||||
async function handleSave(data: any) {
|
||||
try {
|
||||
if (data.id) {
|
||||
// Update
|
||||
const { id, ...updateData } = data
|
||||
await busStopsService.updateBusStop(id, updateData)
|
||||
} else {
|
||||
// Create
|
||||
await busStopsService.createBusStop(data)
|
||||
}
|
||||
await loadStops()
|
||||
closeEditor()
|
||||
} catch (e) {
|
||||
alert('Error al guardar la parada')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(stop: BusStop) {
|
||||
if (confirm(`¿Estás seguro de que quieres eliminar la parada ${stop.name}?`)) {
|
||||
try {
|
||||
await busStopsService.deleteBusStop(stop.id)
|
||||
await loadStops()
|
||||
} catch (e) {
|
||||
alert('Error al eliminar la parada')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-bus-stops {
|
||||
padding: 20px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: var(--hover-bg);
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.add-button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stops-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stop-card {
|
||||
background: white;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stop-info h3 {
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.stop-info p {
|
||||
margin: 0 0 8px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: #e9ecef;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stop-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.edit { color: #f39c12; }
|
||||
.delete { color: #e74c3c; }
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
521
frontend/src/views/AdminDashboard.vue
Normal file
521
frontend/src/views/AdminDashboard.vue
Normal file
@ -0,0 +1,521 @@
|
||||
<template>
|
||||
<div class="admin-dashboard">
|
||||
<div class="dashboard-header">
|
||||
<div class="header-main">
|
||||
<button class="back-btn" @click="router.push('/admin')">
|
||||
<span class="material-icons">arrow_back</span>
|
||||
</button>
|
||||
<div>
|
||||
<h1>Análisis Estratégico</h1>
|
||||
<p class="subtitle">Métricas en tiempo real del ecosistema SIBU</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div v-if="lastRefreshed" class="last-sync">
|
||||
Actualizado: {{ lastRefreshed }}
|
||||
</div>
|
||||
<button class="refresh-action" @click="loadStats" :disabled="isLoading">
|
||||
<span class="material-icons" :class="{ 'spin': isLoading }">refresh</span>
|
||||
{{ isLoading ? 'Sincronizando...' : 'Refrescar' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading && !stats.total_events" class="loading-overlay">
|
||||
<div class="loader-content">
|
||||
<div class="spinner"></div>
|
||||
<p>Procesando datos...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="dashboard-grid">
|
||||
<!-- Top Overview Metrics -->
|
||||
<div class="metrics-row">
|
||||
<div class="metric-card primary">
|
||||
<div class="card-icon"><span class="material-icons">analytics</span></div>
|
||||
<div class="card-info">
|
||||
<label>Eventos Totales</label>
|
||||
<h3>{{ stats.total_events?.toLocaleString() || '0' }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="card-icon"><span class="material-icons">speed</span></div>
|
||||
<div class="card-info">
|
||||
<label>Pico de Actividad</label>
|
||||
<h3>{{ stats.peak_hours?.[0]?.hour || '--' }}:00</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="card-icon"><span class="material-icons">visibility</span></div>
|
||||
<div class="card-info">
|
||||
<label>Pantalla Principal</label>
|
||||
<h3>{{ stats.screen_activity?.[0]?.name || 'Mapa' }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Rows -->
|
||||
<div class="stats-row">
|
||||
<div class="chart-box flex-66">
|
||||
<div class="box-header">
|
||||
<h3>Tendencia de Uso (Últimos 7 días)</h3>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<Line v-if="trendChartData" :data="trendChartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-box flex-33">
|
||||
<div class="box-header">
|
||||
<h3>Distribución por Idioma</h3>
|
||||
</div>
|
||||
<div class="chart-container circle">
|
||||
<Doughnut v-if="langChartData" :data="langChartData" :options="doughnutOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="chart-box flex-50">
|
||||
<div class="box-header">
|
||||
<h3>Popularidad de Rutas</h3>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<Bar v-if="routesChartData" :data="routesChartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-box flex-50">
|
||||
<div class="box-header">
|
||||
<h3>Visualizaciones de Promociones</h3>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<Bar v-if="promosChartData" :data="promosChartData" :options="horizontalBarOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="chart-box flex-50">
|
||||
<div class="box-header">
|
||||
<h3>Top Acciones (Taxis/Otros)</h3>
|
||||
</div>
|
||||
<div class="plain-list">
|
||||
<div v-for="(taxi, idx) in stats.top_taxis" :key="idx" class="list-row">
|
||||
<span class="rank">{{ Number(idx) + 1 }}</span>
|
||||
<span class="label">{{ taxi.id || 'N/A' }}</span>
|
||||
<span class="val">{{ taxi.count }}</span>
|
||||
</div>
|
||||
<p v-if="!stats.top_taxis?.length" class="empty">Sin registros</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-box flex-50">
|
||||
<div class="box-header">
|
||||
<h3>Top Paradas</h3>
|
||||
</div>
|
||||
<div class="plain-list">
|
||||
<div v-for="(stop, idx) in stats.top_stops" :key="idx" class="list-row">
|
||||
<span class="rank">{{ Number(idx) + 1 }}</span>
|
||||
<span class="label">{{ stop.id || 'N/A' }}</span>
|
||||
<span class="val">{{ stop.count }}</span>
|
||||
</div>
|
||||
<p v-if="!stats.top_stops?.length" class="empty">Sin registros</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="chart-box flex-100">
|
||||
<div class="box-header">
|
||||
<h3>Actividad por Hora</h3>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<Bar v-if="hoursChartData" :data="hoursChartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
import { analyticsService } from '@/services/analyticsService'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement
|
||||
} from 'chart.js'
|
||||
import { Line, Bar, Doughnut } from 'vue-chartjs'
|
||||
|
||||
// Register ChartJS
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
)
|
||||
|
||||
const isLoading = ref(true)
|
||||
const stats = ref<any>({})
|
||||
const lastRefreshed = ref('')
|
||||
|
||||
async function loadStats() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
stats.value = await analyticsService.getStats()
|
||||
lastRefreshed.value = new Date().toLocaleTimeString()
|
||||
} catch (e) {
|
||||
console.error('Error loading stats')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Chart Configurations
|
||||
const chartOptions = computed<any>(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
||||
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const horizontalBarOptions = computed<any>(() => ({
|
||||
indexAxis: 'y' as const,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
||||
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
|
||||
},
|
||||
y: {
|
||||
grid: { display: false },
|
||||
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const doughnutOptions = computed<any>(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { color: '#fff', padding: 20 }
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Computed Chart Data
|
||||
const trendChartData = computed<any>(() => {
|
||||
if (!stats.value.daily_trends?.length) return null
|
||||
return {
|
||||
labels: stats.value.daily_trends.map((t: any) => t.date.split('-').slice(1).join('/')),
|
||||
datasets: [{
|
||||
label: 'Eventos',
|
||||
data: stats.value.daily_trends.map((t: any) => t.count),
|
||||
borderColor: '#fee715',
|
||||
backgroundColor: 'rgba(254, 231, 21, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
const langChartData = computed<any>(() => {
|
||||
if (!stats.value.languages?.length) return null
|
||||
return {
|
||||
labels: stats.value.languages.map((l: any) => l.id === 'es' ? 'Español' : 'English'),
|
||||
datasets: [{
|
||||
data: stats.value.languages.map((l: any) => l.count),
|
||||
backgroundColor: ['#fee715', '#64748b'],
|
||||
borderWidth: 0
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
const promosChartData = computed<any>(() => {
|
||||
if (!stats.value.top_promos?.length) return null
|
||||
return {
|
||||
labels: stats.value.top_promos.map((p: any) => p.id),
|
||||
datasets: [{
|
||||
label: 'Visualizaciones',
|
||||
data: stats.value.top_promos.map((p: any) => p.count),
|
||||
backgroundColor: '#fee715',
|
||||
borderRadius: 6
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
const routesChartData = computed<any>(() => {
|
||||
if (!stats.value.top_routes?.length) return null
|
||||
return {
|
||||
labels: stats.value.top_routes.map((r: any) => r.id),
|
||||
datasets: [{
|
||||
label: 'Consultas',
|
||||
data: stats.value.top_routes.map((r: any) => r.count),
|
||||
backgroundColor: '#fee715',
|
||||
borderRadius: 8
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
const hoursChartData = computed<any>(() => {
|
||||
if (!stats.value.peak_hours?.length) return null
|
||||
// Sort by hour
|
||||
const sorted = [...stats.value.peak_hours].sort((a,b) => a.hour - b.hour)
|
||||
return {
|
||||
labels: sorted.map(h => `${h.hour}h`),
|
||||
datasets: [{
|
||||
label: 'Actividad',
|
||||
data: sorted.map(h => h.count),
|
||||
backgroundColor: 'rgba(254, 231, 21, 0.2)',
|
||||
hoverBackgroundColor: '#fee715',
|
||||
borderRadius: 4
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(loadStats)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-dashboard {
|
||||
padding: 32px 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 48px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: var(--hover-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.back-btn:hover { background: var(--active-color); color: #101820; transform: scale(1.05); }
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2rem, 5vw, 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;
|
||||
}
|
||||
|
||||
.subtitle { color: var(--text-secondary); margin: 6px 0 0 0; font-weight: 500; }
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.last-sync { font-size: 13px; color: var(--text-secondary); font-weight: 600; }
|
||||
|
||||
.refresh-action {
|
||||
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;
|
||||
box-shadow: 0 10px 20px rgba(254, 231, 21, 0.2);
|
||||
}
|
||||
|
||||
.refresh-action:hover { transform: translateY(-2px); box-shadow: 0 15px 30px rgba(254, 231, 21, 0.3); }
|
||||
.refresh-action:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.spin { animation: rotation 1s infinite linear; }
|
||||
@keyframes rotation { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
|
||||
/* Metrics */
|
||||
.metrics-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 28px;
|
||||
border-radius: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.metric-card:hover { transform: translateY(-4px); border-color: var(--active-color); }
|
||||
|
||||
.metric-card.primary {
|
||||
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
|
||||
color: #101820;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.metric-card.primary .card-icon { background: rgba(0,0,0,0.1); }
|
||||
|
||||
.card-icon .material-icons { font-size: 32px; color: var(--active-color); }
|
||||
.metric-card.primary .card-icon .material-icons { color: #101820; }
|
||||
|
||||
.card-info label { display: block; font-size: 13px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.1em; font-weight: 800; margin-bottom: 4px; }
|
||||
.metric-card.primary .card-info label { color: #101820; opacity: 0.7; }
|
||||
.card-info h3 { font-size: 28px; font-weight: 900; margin: 0; letter-spacing: -0.02em; }
|
||||
|
||||
/* Charts */
|
||||
.dashboard-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.chart-box {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 32px;
|
||||
padding: 32px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.box-header { margin-bottom: 32px; }
|
||||
.box-header h3 { font-size: 1.25rem; font-weight: 900; color: var(--text-primary); display: flex; align-items: center; gap: 12px; letter-spacing: -0.02em; }
|
||||
|
||||
.chart-container { height: 320px; position: relative; }
|
||||
.chart-container.circle { height: 280px; }
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.flex-33 { flex: 1; }
|
||||
.flex-50 { flex: 1; }
|
||||
.flex-66 { flex: 2; }
|
||||
.flex-100 { flex: 1; width: 100%; }
|
||||
|
||||
/* Plain Lists */
|
||||
.plain-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.list-row:hover { transform: translateX(8px); border-color: var(--active-color); }
|
||||
|
||||
.rank { width: 32px; color: var(--text-secondary); font-weight: 900; font-size: 14px; }
|
||||
.label { flex: 1; font-weight: 700; color: var(--text-primary); }
|
||||
.val { font-weight: 900; color: var(--active-color); font-size: 1.1rem; }
|
||||
|
||||
.empty { text-align: center; color: var(--text-secondary); padding: 48px; font-weight: 600; }
|
||||
|
||||
.loading-overlay {
|
||||
height: 60vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loader-content { text-align: center; }
|
||||
.spinner {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 4px solid var(--border-color);
|
||||
border-top-color: var(--active-color);
|
||||
border-radius: 50%;
|
||||
animation: rotation 1s infinite linear;
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row { flex-direction: column; }
|
||||
.metrics-row { grid-template-columns: 1fr; }
|
||||
.dashboard-header { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
</style>
|
||||
1163
frontend/src/views/AdminDrivers.vue
Normal file
1163
frontend/src/views/AdminDrivers.vue
Normal file
File diff suppressed because it is too large
Load Diff
280
frontend/src/views/AdminPanel.vue
Normal file
280
frontend/src/views/AdminPanel.vue
Normal file
@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div class="admin-panel">
|
||||
<div class="header-section">
|
||||
<div class="badge">SISTEMA CENTRAL</div>
|
||||
<h1>Panel de Control</h1>
|
||||
<p class="subtitle">Ecosistema Administrativo SIBU</p>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-sections">
|
||||
<!-- Sector: Inteligencia y Control -->
|
||||
<section class="admin-section">
|
||||
<div class="section-header">
|
||||
<span class="material-icons">insights</span>
|
||||
<h2>Inteligencia y Control</h2>
|
||||
</div>
|
||||
<div class="category-grid">
|
||||
<div class="action-card" @click="router.push('/admin/analytics')">
|
||||
<div class="card-icon"><span class="material-icons">analytics</span></div>
|
||||
<div class="card-content">
|
||||
<h3>Análisis</h3>
|
||||
<p>Métricas en tiempo real.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-card" @click="router.push('/admin/reports')">
|
||||
<div class="card-icon"><span class="material-icons">report_problem</span></div>
|
||||
<div class="card-content">
|
||||
<h3>Reportes</h3>
|
||||
<p>Incidencias de usuarios.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sector: Infraestructura de Transporte -->
|
||||
<section class="admin-section">
|
||||
<div class="section-header">
|
||||
<span class="material-icons">settings_input_component</span>
|
||||
<h2>Infraestructura</h2>
|
||||
</div>
|
||||
<div class="category-grid">
|
||||
<div class="action-card" @click="router.push('/admin/routes')">
|
||||
<div class="card-icon"><span class="material-icons">navigation</span></div>
|
||||
<div class="card-content">
|
||||
<h3>Rutas</h3>
|
||||
<p>Gestión de trayectos.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-card" @click="router.push('/admin/bus-stops')">
|
||||
<div class="card-icon"><span class="material-icons">location_on</span></div>
|
||||
<div class="card-content">
|
||||
<h3>Paradas</h3>
|
||||
<p>Puntos de abordaje.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-card" @click="router.push('/admin/schedules')">
|
||||
<div class="card-icon"><span class="material-icons">schedule</span></div>
|
||||
<div class="card-content">
|
||||
<h3>Horarios</h3>
|
||||
<p>Frecuencias y salidas.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sector: Flota y Servicios -->
|
||||
<section class="admin-section">
|
||||
<div class="section-header">
|
||||
<span class="material-icons">delivery_dining</span>
|
||||
<h2>Flota y Servicios</h2>
|
||||
</div>
|
||||
<div class="category-grid">
|
||||
<div class="action-card" @click="router.push('/admin/shuttles')">
|
||||
<div class="card-icon"><span class="material-icons">airport_shuttle</span></div>
|
||||
<div class="card-content">
|
||||
<h3>Shuttles</h3>
|
||||
<p>Viajes turísticos.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-card" @click="router.push('/admin/taxis')">
|
||||
<div class="card-icon"><span class="material-icons">local_taxi</span></div>
|
||||
<div class="card-content">
|
||||
<h3>Taxis</h3>
|
||||
<p>Directorio de apoyo.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-card" @click="router.push('/admin/drivers')">
|
||||
<div class="card-icon"><span class="material-icons">badge</span></div>
|
||||
<div class="card-content">
|
||||
<h3>Conductores</h3>
|
||||
<p>Gestión de personal.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sector: Comercial -->
|
||||
<section class="admin-section">
|
||||
<div class="section-header">
|
||||
<span class="material-icons">hub</span>
|
||||
<h2>Ecosistema Comercial</h2>
|
||||
</div>
|
||||
<div class="category-grid">
|
||||
<div class="action-card promoter-card" @click="router.push('/promoter')">
|
||||
<div class="card-icon"><span class="material-icons">storefront</span></div>
|
||||
<div class="card-content">
|
||||
<h3>Negocios</h3>
|
||||
<p>Promos y locales.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel {
|
||||
padding: 60px 24px 120px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
background: rgba(254, 231, 21, 0.1);
|
||||
color: var(--active-color);
|
||||
border-radius: 100px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.15em;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid rgba(254, 231, 21, 0.2);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.2rem, 5vw, 3.2rem);
|
||||
font-weight: 900;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.04em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
margin-top: 6px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.dashboard-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 56px;
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
align-items: center; /* Centra el contenido de la sección */
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--active-color);
|
||||
padding: 0 20px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
width: 100%;
|
||||
max-width: 800px; /* Línea de división elegante y no tan larga */
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.section-header .material-icons {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center; /* ESTO CENTRA LAS TARJETAS */
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 24px;
|
||||
padding: 24px 28px;
|
||||
border: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
width: 340px; /* Ancho fijo para mantener la simetría */
|
||||
min-height: 110px;
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: var(--active-color);
|
||||
background: rgba(254, 231, 21, 0.03);
|
||||
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.action-card:hover .card-icon {
|
||||
background: var(--active-color);
|
||||
transform: rotate(-10deg);
|
||||
}
|
||||
|
||||
.card-icon .material-icons {
|
||||
font-size: 24px;
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
.action-card:hover .card-icon .material-icons {
|
||||
color: #101820;
|
||||
}
|
||||
|
||||
.card-content h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card-content p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.promoter-card {
|
||||
background: linear-gradient(135deg, rgba(254, 231, 21, 0.05) 0%, rgba(30, 41, 59, 0.2) 100%);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.admin-panel { padding: 30px 16px 120px; }
|
||||
.category-grid { grid-template-columns: 1fr; }
|
||||
.header-section { text-align: center; padding: 0; }
|
||||
}
|
||||
</style>
|
||||
318
frontend/src/views/AdminReports.vue
Normal file
318
frontend/src/views/AdminReports.vue
Normal file
@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div class="admin-reports">
|
||||
<div class="header">
|
||||
<button class="back-link" @click="$router.push('/admin')">
|
||||
<span class="material-icons">arrow_back</span> Volver al Panel
|
||||
</button>
|
||||
<h1>Reportes de Usuarios</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Cargando reportes...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="reports.length > 0" class="reports-container">
|
||||
<div class="stats-overview">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{{ reports.length }}</span>
|
||||
<span class="stat-label">Total Reportes</span>
|
||||
</div>
|
||||
<div class="stat-card pending">
|
||||
<span class="stat-value">{{ reports.filter(r => r.status === 'pending').length }}</span>
|
||||
<span class="stat-label">Pendientes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reports-grid">
|
||||
<div v-for="report in sortedReports" :key="report.id" class="report-card" :class="report.status">
|
||||
<div class="report-header">
|
||||
<div class="user-info">
|
||||
<span class="material-icons">account_circle</span>
|
||||
<div>
|
||||
<h3>{{ report.user_name || 'Usuario Anónimo' }}</h3>
|
||||
<span class="date">{{ formatDate(report.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-badge" :class="report.status">
|
||||
{{ statusDisplay(report.status) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="report-body">
|
||||
<p>{{ report.message }}</p>
|
||||
</div>
|
||||
|
||||
<div class="report-actions">
|
||||
<button
|
||||
v-if="report.status === 'pending'"
|
||||
@click="handleUpdateStatus(report.id, 'resolved')"
|
||||
class="btn-resolve"
|
||||
>
|
||||
<span class="material-icons">check_circle</span> Marcar como Resuelto
|
||||
</button>
|
||||
<button
|
||||
v-else-if="report.status === 'resolved'"
|
||||
@click="handleUpdateStatus(report.id, 'archived')"
|
||||
class="btn-archive"
|
||||
>
|
||||
<span class="material-icons">archive</span> Archivar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-results">
|
||||
<span class="material-icons">info</span>
|
||||
<p>No hay reportes nuevos en este momento.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { reportsService, type Report } from '@/services/reportsService'
|
||||
|
||||
const reports = ref<Report[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
const sortedReports = computed(() => {
|
||||
return [...reports.value].sort((a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchReports()
|
||||
})
|
||||
|
||||
async function fetchReports() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
reports.value = await reportsService.getReports()
|
||||
} catch (e) {
|
||||
console.error('Error fetching reports:', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateStatus(id: string, status: string) {
|
||||
try {
|
||||
await reportsService.updateReportStatus(id, status)
|
||||
await fetchReports()
|
||||
} catch (e) {
|
||||
alert('Error al actualizar el estado del reporte')
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function statusDisplay(status: string) {
|
||||
const mapping: Record<string, string> = {
|
||||
'pending': 'Pendiente',
|
||||
'resolved': 'Resuelto',
|
||||
'archived': 'Archivado'
|
||||
}
|
||||
return mapping[status] || status
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-reports {
|
||||
padding: 48px 24px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stats-overview {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--card-bg);
|
||||
padding: 24px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 900;
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.reports-grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.report-card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.report-card:hover {
|
||||
border-color: rgba(254, 231, 21, 0.3);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-info h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.user-info .date {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 100px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge.pending { background: rgba(239, 68, 68, 0.1); color: #ef4444; }
|
||||
.status-badge.resolved { background: rgba(34, 197, 94, 0.1); color: #22c55e; }
|
||||
.status-badge.archived { background: var(--bg-secondary); color: var(--text-secondary); }
|
||||
|
||||
.report-body {
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.report-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-resolve, .btn-archive {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-resolve {
|
||||
background: var(--active-color);
|
||||
color: #101820;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-resolve:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 15px rgba(254, 231, 21, 0.3);
|
||||
}
|
||||
|
||||
.btn-archive {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-archive:hover {
|
||||
border-color: var(--text-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.no-results, .loading {
|
||||
text-align: center;
|
||||
padding: 80px 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(254, 231, 21, 0.1);
|
||||
border-top-color: var(--active-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.admin-reports { padding: 24px 16px; }
|
||||
.stats-overview { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
592
frontend/src/views/AdminRoutes.vue
Normal file
592
frontend/src/views/AdminRoutes.vue
Normal file
@ -0,0 +1,592 @@
|
||||
<template>
|
||||
<div class="admin-routes">
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
</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 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
import { routesService } from '@/services/routesService'
|
||||
import { busStopsService } from '@/services/busStopsService'
|
||||
import type { Route, BusStop } from '@/types'
|
||||
|
||||
const routes = ref<Route[]>([])
|
||||
const allStops = ref<BusStop[]>([])
|
||||
const selectedRoute = ref<Route | null>(null)
|
||||
const routeStops = ref<BusStop[]>([])
|
||||
const newStopId = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
routes.value = await routesService.getAllRoutes()
|
||||
allStops.value = await busStopsService.getAllBusStops()
|
||||
})
|
||||
|
||||
const availableStops = computed(() => {
|
||||
const currentIds = new Set(routeStops.value.map((s: BusStop) => s.id))
|
||||
return allStops.value.filter((s: BusStop) => !currentIds.has(s.id))
|
||||
})
|
||||
|
||||
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 speedKmPerMin = speed / 60
|
||||
|
||||
const times: number[] = []
|
||||
let currentTime = 0 // minutes from start
|
||||
|
||||
// First stop is at 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
|
||||
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
|
||||
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'
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
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')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-routes {
|
||||
padding: 32px 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.add-stop button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.stops-list-editor {
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.stop-item:hover {
|
||||
border-color: var(--active-color);
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
.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; }
|
||||
}
|
||||
</style>
|
||||
613
frontend/src/views/AdminSchedules.vue
Normal file
613
frontend/src/views/AdminSchedules.vue
Normal file
@ -0,0 +1,613 @@
|
||||
<template>
|
||||
<div class="admin-schedules">
|
||||
<div class="glass-container">
|
||||
<div class="header">
|
||||
<button class="back-link" @click="router.push('/admin')">
|
||||
<span class="material-icons">arrow_back</span>
|
||||
Volver al Panel
|
||||
</button>
|
||||
<h1 class="premium-title">Gestión de Horarios</h1>
|
||||
</div>
|
||||
|
||||
<!-- Route Selection -->
|
||||
<div class="selection-card glass-morphism">
|
||||
<div class="input-w-icon">
|
||||
<span class="material-icons">route</span>
|
||||
<select v-model="selectedRouteId" @change="loadSchedules" class="premium-select">
|
||||
<option value="">-- Selecciona una ruta para gestionar --</option>
|
||||
<option v-for="route in routes" :key="route.id" :value="route.id">
|
||||
{{ route.name }} ({{ route.origin_city }} → {{ route.destination_city }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedRouteId" class="schedules-content">
|
||||
<div class="actions-header">
|
||||
<h2 class="section-title">Horarios Configurados</h2>
|
||||
<button class="add-btn premium-btn" @click="showAddForm = true">
|
||||
<span class="material-icons">add</span> Nuevo Horario
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit form -->
|
||||
<Transition name="fade">
|
||||
<div v-if="showAddForm || editingSchedule" class="schedule-form-card glass-morphism active-border">
|
||||
<h3 class="form-title">{{ editingSchedule ? 'Editar Horario' : 'Agregar Nuevo Horario' }}</h3>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Hora de Salida</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="material-icons">schedule</span>
|
||||
<input v-model="form.departure_time" type="time" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Frecuencia (min)</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="material-icons">update</span>
|
||||
<input v-model.number="form.frequency_minutes" type="number" placeholder="30">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tipo de Día</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="material-icons">calendar_today</span>
|
||||
<select v-model="form.schedule_type">
|
||||
<option value="weekday">Día de Semana</option>
|
||||
<option value="weekend">Fin de Semana</option>
|
||||
<option value="holiday">Feriado</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group toggle-group">
|
||||
<label class="switch-label">
|
||||
<span>Publicado</span>
|
||||
<input v-model="form.is_published" type="checkbox" class="retro-checkbox">
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group toggle-group">
|
||||
<label class="switch-label">
|
||||
<span>Activo (Operativo)</span>
|
||||
<input v-model="form.is_active" type="checkbox" class="retro-checkbox">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn-secondary" @click="cancelForm">Cancelar</button>
|
||||
<button class="btn-primary" @click="saveSchedule">Guardar Horario</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Schedules List -->
|
||||
<div class="schedules-list">
|
||||
<div v-if="isLoadingSchedules" class="loader-container">
|
||||
<span class="material-icons spin">refresh</span>
|
||||
<p>Cargando horarios...</p>
|
||||
</div>
|
||||
<div v-else-if="schedules.length === 0" class="empty-state glass-morphism">
|
||||
<span class="material-icons">event_busy</span>
|
||||
<p>No hay horarios configurados para esta ruta.</p>
|
||||
</div>
|
||||
<div v-else class="grid-container">
|
||||
<div
|
||||
v-for="schedule in sortedSchedules"
|
||||
:key="schedule.id"
|
||||
class="schedule-card-premium glass-morphism"
|
||||
:class="{ 'draft-card': !schedule.is_published }"
|
||||
>
|
||||
<div class="card-left">
|
||||
<div class="time-display">{{ formatTo12Hour(schedule.departure_time) }}</div>
|
||||
<div class="type-tag" :class="schedule.schedule_type">
|
||||
{{ translateType(schedule.schedule_type) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-right">
|
||||
<div class="status-indicator">
|
||||
<span class="dot" :class="{ 'online': schedule.is_published }"></span>
|
||||
{{ schedule.is_published ? 'Publicado' : 'Borrador' }}
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button class="icon-btn edit-btn" @click="editSchedule(schedule)" title="Editar">
|
||||
<span class="material-icons">edit</span>
|
||||
</button>
|
||||
<button class="icon-btn delete-btn" @click="handleDelete(schedule.id)" title="Eliminar">
|
||||
<span class="material-icons">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-selection-state">
|
||||
<div class="icon-circle">
|
||||
<span class="material-icons">list_alt</span>
|
||||
</div>
|
||||
<h3>Gestión de Despachos</h3>
|
||||
<p>Selecciona una ruta del menú superior para administrar los horarios de salida y frecuencia.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { routesService } from '@/services/routesService'
|
||||
import { schedulesService } from '@/services/schedulesService'
|
||||
import { formatTo12Hour } from '@/utils/timeFormatter'
|
||||
|
||||
const router = useRouter()
|
||||
const routes = ref<any[]>([])
|
||||
const selectedRouteId = ref('')
|
||||
const schedules = ref<any[]>([])
|
||||
const showAddForm = ref(false)
|
||||
const editingSchedule = ref<any>(null)
|
||||
const isLoadingSchedules = ref(false)
|
||||
|
||||
const form = ref({
|
||||
departure_time: '06:00',
|
||||
frequency_minutes: 30,
|
||||
schedule_type: 'weekday',
|
||||
is_published: true,
|
||||
is_active: true
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
routes.value = await routesService.getAllRoutes()
|
||||
} catch (e) {
|
||||
console.error('Error loading routes', e)
|
||||
}
|
||||
})
|
||||
|
||||
const sortedSchedules = computed(() => {
|
||||
return [...schedules.value].sort((a, b) => a.departure_time.localeCompare(b.departure_time))
|
||||
})
|
||||
|
||||
async function loadSchedules() {
|
||||
if (!selectedRouteId.value) {
|
||||
schedules.value = []
|
||||
return
|
||||
}
|
||||
isLoadingSchedules.value = true
|
||||
try {
|
||||
// Get all schedules including drafts (false for onlyPublished)
|
||||
schedules.value = await schedulesService.getRouteSchedules(selectedRouteId.value, false)
|
||||
} catch (e) {
|
||||
console.error('Error loading schedules', e)
|
||||
} finally {
|
||||
isLoadingSchedules.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function translateType(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
'weekday': 'Día de Semana',
|
||||
'weekend': 'Fin de Semana',
|
||||
'holiday': 'Feriado'
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
function editSchedule(schedule: any) {
|
||||
editingSchedule.value = schedule
|
||||
form.value = {
|
||||
departure_time: schedule.departure_time,
|
||||
frequency_minutes: schedule.frequency_minutes,
|
||||
schedule_type: schedule.schedule_type,
|
||||
is_published: schedule.is_published,
|
||||
is_active: schedule.is_active
|
||||
}
|
||||
showAddForm.value = true
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
showAddForm.value = false
|
||||
editingSchedule.value = null
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
departure_time: '06:00',
|
||||
frequency_minutes: 30,
|
||||
schedule_type: 'weekday',
|
||||
is_published: true,
|
||||
is_active: true
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSchedule() {
|
||||
try {
|
||||
if (editingSchedule.value) {
|
||||
await schedulesService.updateSchedule(editingSchedule.value.id, form.value)
|
||||
} else {
|
||||
await schedulesService.createSchedule({
|
||||
...form.value,
|
||||
route_id: selectedRouteId.value
|
||||
})
|
||||
}
|
||||
await loadSchedules()
|
||||
cancelForm()
|
||||
} catch (e: any) {
|
||||
console.error('Save error details:', e.response?.data || e)
|
||||
const errorMsg = e.response?.data?.detail
|
||||
? (typeof e.response.data.detail === 'string' ? e.response.data.detail : JSON.stringify(e.response.data.detail))
|
||||
: 'Error de conexión con el servidor'
|
||||
alert('Error al guardar: ' + errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('¿Estás seguro de eliminar este horario?')) return
|
||||
try {
|
||||
await schedulesService.deleteSchedule(id)
|
||||
await loadSchedules()
|
||||
} catch (e: any) {
|
||||
console.error('Delete error:', e.response?.data || e)
|
||||
const errorMsg = e.response?.data?.detail
|
||||
? (typeof e.response.data.detail === 'string' ? e.response.data.detail : JSON.stringify(e.response.data.detail))
|
||||
: 'Error al eliminar el registro'
|
||||
alert('Error: ' + errorMsg)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-schedules {
|
||||
padding: 40px 20px;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.glass-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 16px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
width: fit-content;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
transform: translateX(-4px);
|
||||
background: var(--hover-bg);
|
||||
border-color: var(--active-color);
|
||||
}
|
||||
|
||||
.premium-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.glass-morphism {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.selection-card {
|
||||
padding: 24px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.input-w-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 12px 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.input-w-icon .material-icons {
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
.premium-select {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.premium-btn {
|
||||
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
|
||||
color: #101820;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 14px;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 10px 20px rgba(254, 231, 21, 0.2);
|
||||
}
|
||||
|
||||
.premium-btn:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 15px 30px rgba(254, 231, 21, 0.3);
|
||||
}
|
||||
|
||||
.schedule-form-card {
|
||||
padding: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.active-border {
|
||||
border-color: var(--active-color);
|
||||
}
|
||||
|
||||
.form-title {
|
||||
margin-bottom: 24px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1.5px solid var(--border-color);
|
||||
}
|
||||
|
||||
.input-wrapper input, .input-wrapper select {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.retro-checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
accent-color: var(--active-color);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--active-color);
|
||||
color: #101820;
|
||||
border: none;
|
||||
padding: 12px 28px;
|
||||
border-radius: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 12px 28px;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.schedule-card-premium {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.schedule-card-premium:hover {
|
||||
transform: scale(1.02);
|
||||
border-color: var(--active-color);
|
||||
}
|
||||
|
||||
.draft-card {
|
||||
opacity: 0.6;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.time-display {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 900;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.type-tag.weekday { background: rgba(52, 152, 219, 0.1); color: #3498db; }
|
||||
.type-tag.weekend { background: rgba(155, 89, 182, 0.1); color: #9b59b2; }
|
||||
.type-tag.holiday { background: rgba(231, 76, 60, 0.1); color: #e74c3c; }
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #95a5a6;
|
||||
}
|
||||
|
||||
.dot.online {
|
||||
background: #2ecc71;
|
||||
box-shadow: 0 0 8px #2ecc71;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.edit-btn:hover { background: var(--hover-bg); color: var(--active-color); border-color: var(--active-color); }
|
||||
.delete-btn:hover { background: #fff0f0; color: #e74c3c; border-color: #ef4444; }
|
||||
|
||||
.no-selection-state {
|
||||
text-align: center;
|
||||
padding: 80px 40px;
|
||||
}
|
||||
|
||||
.icon-circle {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
|
||||
.icon-circle .material-icons {
|
||||
font-size: 3rem;
|
||||
color: var(--active-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.no-selection-state h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.no-selection-state p {
|
||||
color: var(--text-secondary);
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loader-container {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
display: block;
|
||||
margin: 0 auto 12px;
|
||||
font-size: 2rem;
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
701
frontend/src/views/AdminShuttles.vue
Normal file
701
frontend/src/views/AdminShuttles.vue
Normal file
@ -0,0 +1,701 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { API_URL } from '@/services/apiClient';
|
||||
import axios from 'axios';
|
||||
|
||||
const router = useRouter();
|
||||
const isLoading = ref(false);
|
||||
const showMessage = ref({ text: '', type: '' });
|
||||
|
||||
const selectedFile = ref<File | null>(null);
|
||||
const selectedFileName = ref('');
|
||||
|
||||
// Form state
|
||||
const shuttleForm = ref({
|
||||
company_name: 'Chiriqui Transfers',
|
||||
origin: 'Boquete',
|
||||
destination: 'Santa Catalina',
|
||||
vehicle_type: 'Mini Van Compartida',
|
||||
price_per_person: 35,
|
||||
price_private_trip: 180,
|
||||
estimated_duration: '4.5 horas',
|
||||
departure_times: 'Todos los días 8:00 AM',
|
||||
contact_whatsapp: '50712345678',
|
||||
phone_number: '50712345678',
|
||||
english_speaking: true,
|
||||
image_url: '',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
const previewImageUrl = ref('https://images.unsplash.com/photo-1449034446853-66c86144b0ad?q=80&w=2070&auto=format&fit=crop');
|
||||
|
||||
function handleImageChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files[0]) {
|
||||
const file = input.files[0];
|
||||
selectedFile.value = file;
|
||||
selectedFileName.value = file.name;
|
||||
|
||||
// Preview logic
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
previewImageUrl.value = e.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveShuttle() {
|
||||
isLoading.value = true;
|
||||
showMessage.value = { text: '', type: '' };
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const formData = new FormData();
|
||||
|
||||
// Añadimos campos obligatorios para el backend
|
||||
formData.append('route_name', `${shuttleForm.value.origin} - ${shuttleForm.value.destination}`);
|
||||
formData.append('origin', shuttleForm.value.origin);
|
||||
formData.append('destination', shuttleForm.value.destination);
|
||||
formData.append('vehicle_type', shuttleForm.value.vehicle_type);
|
||||
formData.append('company_name', shuttleForm.value.company_name);
|
||||
formData.append('price_per_person', String(shuttleForm.value.price_per_person));
|
||||
formData.append('price_private_trip', String(shuttleForm.value.price_private_trip));
|
||||
formData.append('estimated_duration', shuttleForm.value.estimated_duration);
|
||||
formData.append('departure_times', shuttleForm.value.departure_times);
|
||||
formData.append('contact_whatsapp', shuttleForm.value.contact_whatsapp);
|
||||
formData.append('phone_number', shuttleForm.value.phone_number);
|
||||
formData.append('english_speaking', String(shuttleForm.value.english_speaking));
|
||||
|
||||
if (selectedFile.value) {
|
||||
formData.append('image', selectedFile.value);
|
||||
}
|
||||
|
||||
await axios.post(`${API_URL}/api/shuttles`, formData, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
showMessage.value = { text: '¡Viaje Turístico Desplegado!', type: 'success' };
|
||||
setTimeout(() => router.push('/admin'), 2000);
|
||||
} catch (error: any) {
|
||||
console.error('Error saving shuttle:', error);
|
||||
const errorDetail = error.response?.data?.detail || 'Error en el despliegue del sistema.';
|
||||
showMessage.value = { text: errorDetail, type: 'error' };
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-shuttles-view">
|
||||
<div class="nexus-admin-header">
|
||||
<button class="back-btn" @click="router.push('/admin')">
|
||||
<span class="material-icons">arrow_back</span>
|
||||
</button>
|
||||
<h1>Generador de Shuttles Turísticos</h1>
|
||||
<div class="header-status">ID: SHUTTLE-MARK-I</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-grid-layout">
|
||||
<!-- FORM PANEL -->
|
||||
<section class="form-panel nexus-glass">
|
||||
<div class="section-title">
|
||||
<span class="material-icons">edit_note</span>
|
||||
<h2>Datos del Servicio</h2>
|
||||
</div>
|
||||
|
||||
<div class="nexus-form">
|
||||
<div class="form-group grid-row">
|
||||
<div class="input-box">
|
||||
<label>Nombre de la Empresa</label>
|
||||
<input v-model="shuttleForm.company_name" type="text" placeholder="Ej: Chiriqui Transfers">
|
||||
</div>
|
||||
<div class="input-box">
|
||||
<label>Imagen del Transporte</label>
|
||||
<div class="file-upload-wrapper">
|
||||
<input type="file" @change="handleImageChange" accept="image/*" id="file-input">
|
||||
<label for="file-input" class="file-label">
|
||||
<span class="material-icons">cloud_upload</span>
|
||||
{{ selectedFileName || 'SELECCIONAR IMAGEN' }}
|
||||
</label>
|
||||
<p class="upload-hint">Recomendado: 1200x900px (4:3)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group grid-row">
|
||||
<div class="input-box">
|
||||
<label>Origen</label>
|
||||
<input v-model="shuttleForm.origin" type="text" placeholder="Boquete">
|
||||
</div>
|
||||
<div class="input-box">
|
||||
<label>Destino</label>
|
||||
<input v-model="shuttleForm.destination" type="text" placeholder="Santa Catalina">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tipo de Vehículo</label>
|
||||
<input v-model="shuttleForm.vehicle_type" type="text" placeholder="Mini Van Compartida">
|
||||
</div>
|
||||
|
||||
<div class="form-group grid-row">
|
||||
<div class="input-box">
|
||||
<label>Duración Estimada</label>
|
||||
<input v-model="shuttleForm.estimated_duration" type="text" placeholder="4.5 horas">
|
||||
</div>
|
||||
<div class="input-box">
|
||||
<label>Salidas</label>
|
||||
<input v-model="shuttleForm.departure_times" type="text" placeholder="Todos los días 8:00 AM">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group grid-row">
|
||||
<div class="input-box">
|
||||
<label>Precio por Persona ($)</label>
|
||||
<input v-model="shuttleForm.price_per_person" type="number">
|
||||
</div>
|
||||
<div class="input-box">
|
||||
<label>Precio Viaje Privado ($)</label>
|
||||
<input v-model="shuttleForm.price_private_trip" type="number">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group grid-row">
|
||||
<div class="input-box">
|
||||
<label>WhatsApp (Sin +)</label>
|
||||
<div class="whatsapp-input">
|
||||
<span class="prefix">+</span>
|
||||
<input v-model="shuttleForm.contact_whatsapp" type="text" placeholder="50760000000">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-box">
|
||||
<label>Teléfono de Llamada</label>
|
||||
<input v-model="shuttleForm.phone_number" type="text" placeholder="50760000000">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="toggle-container">
|
||||
<div class="toggle-text">
|
||||
<span class="material-icons">translate</span>
|
||||
<span>¿Habla Inglés? (Bilingüe)</span>
|
||||
</div>
|
||||
<div class="nexus-switch">
|
||||
<input type="checkbox" v-model="shuttleForm.english_speaking">
|
||||
<span class="slider"></span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="deploy-btn" :disabled="isLoading" @click="saveShuttle">
|
||||
<span class="material-icons">{{ isLoading ? 'sync' : 'rocket_launch' }}</span>
|
||||
{{ isLoading ? 'PROCESANDO...' : 'PUBLICAR EN SIBU' }}
|
||||
</button>
|
||||
|
||||
<p v-if="showMessage.text" :class="['message', showMessage.type]">{{ showMessage.text }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PREVIEW PANEL -->
|
||||
<section class="preview-panel">
|
||||
<div class="section-title white">
|
||||
<span class="material-icons">visibility</span>
|
||||
<h2>Previsualización en Directo</h2>
|
||||
</div>
|
||||
|
||||
<div class="preview-container">
|
||||
<!-- LA TARJETA TAL CUAL LA IMAGEN DEL USUARIO -->
|
||||
<div class="shuttle-card-preview" :style="{ backgroundImage: `url(${shuttleForm.image_url})` }">
|
||||
<div class="card-header">
|
||||
<div class="company-badge">
|
||||
<span class="material-icons">business</span>
|
||||
{{ shuttleForm.company_name }}
|
||||
</div>
|
||||
<div class="price-badge-top">
|
||||
${{ shuttleForm.price_per_person }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="route-display">
|
||||
<span class="city">{{ shuttleForm.origin }}</span>
|
||||
<span class="material-icons arrow">arrow_forward</span>
|
||||
<span class="city">{{ shuttleForm.destination }}</span>
|
||||
</div>
|
||||
|
||||
<div class="vehicle-tag">
|
||||
<span class="material-icons">directions_bus</span>
|
||||
{{ shuttleForm.vehicle_type }}
|
||||
</div>
|
||||
|
||||
<div class="card-details-box">
|
||||
<div class="detail-item">
|
||||
<span class="material-icons icon-yellow">schedule</span>
|
||||
<div class="texts">
|
||||
<span class="label">DURACIÓN ESTIMADA</span>
|
||||
<span class="val">{{ shuttleForm.estimated_duration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="material-icons icon-yellow">calendar_today</span>
|
||||
<div class="texts">
|
||||
<span class="label">SALIDAS</span>
|
||||
<span class="val">{{ shuttleForm.departure_times }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer-preview">
|
||||
<div class="price-info">
|
||||
<span class="main-price">${{ shuttleForm.price_per_person }} <small>por persona</small></span>
|
||||
<div class="lang-indicator" v-if="shuttleForm.english_speaking">
|
||||
<span class="material-icons">g_translate</span>
|
||||
ENGLISH
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-actions">
|
||||
<div class="mini-contact-btn phone">
|
||||
<span class="material-icons">phone</span>
|
||||
</div>
|
||||
<div class="mini-contact-btn wa">
|
||||
<span class="material-icons">chat</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="preview-hint">Esta es la apariencia final que verán los usuarios en su app móvil.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-shuttles-view {
|
||||
min-height: 100vh;
|
||||
background: #0f172a;
|
||||
color: white;
|
||||
padding: 40px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.nexus-admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nexus-admin-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #fff 0%, #94a3b8 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
padding: 4px 12px;
|
||||
background: rgba(254, 231, 21, 0.1);
|
||||
color: #fee715;
|
||||
border: 1px solid rgba(254, 231, 21, 0.2);
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.admin-grid-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 450px;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.nexus-glass {
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 32px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 32px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.section-title h2 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-title.white {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nexus-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.grid-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
border-color: #fee715;
|
||||
box-shadow: 0 0 0 4px rgba(254, 231, 21, 0.1);
|
||||
}
|
||||
|
||||
.file-upload-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-upload-wrapper input[type="file"] {
|
||||
position: absolute;
|
||||
width: 0.1px;
|
||||
height: 0.1px;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.file-label {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background: rgba(254, 231, 21, 0.1) !important;
|
||||
border: 1px dashed #fee715 !important;
|
||||
padding: 12px !important;
|
||||
border-radius: 12px;
|
||||
color: #fee715 !important;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem !important;
|
||||
}
|
||||
|
||||
.file-label:hover {
|
||||
background: rgba(254, 231, 21, 0.2) !important;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
margin-top: 6px;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.whatsapp-input {
|
||||
display: flex;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.whatsapp-input .prefix {
|
||||
padding: 14px 16px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.whatsapp-input input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.deploy-btn {
|
||||
margin-top: 20px;
|
||||
background: #fee715;
|
||||
color: #101820;
|
||||
border: none;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.deploy-btn:hover:not(:disabled) {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 20px rgba(254, 231, 21, 0.2);
|
||||
}
|
||||
|
||||
.deploy-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* SHUTTLE PREVIEW CARD STYLES */
|
||||
.shuttle-card-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 1.2;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
box-shadow: 0 30px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.shuttle-card-preview::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.2) 60%, rgba(0,0,0,0.4) 100%);
|
||||
}
|
||||
|
||||
.card-header, .route-display, .vehicle-tag, .card-details-box, .card-footer-preview {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.company-badge {
|
||||
background: rgba(254, 231, 21, 0.2);
|
||||
backdrop-filter: blur(8px);
|
||||
color: #fee715;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
border: 1px solid rgba(254, 231, 21, 0.3);
|
||||
}
|
||||
|
||||
.price-badge-top {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: #fee715;
|
||||
color: #101820;
|
||||
padding: 8px 16px;
|
||||
border-radius: 12px;
|
||||
font-weight: 900;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.route-display {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.route-display .arrow {
|
||||
color: #fee715;
|
||||
}
|
||||
|
||||
.vehicle-tag {
|
||||
margin-top: 12px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
padding: 8px 16px;
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: fit-content;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.card-details-box {
|
||||
margin-top: auto;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.icon-yellow { color: #fee715; font-size: 20px; }
|
||||
|
||||
.lang-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(254, 231, 21, 0.2);
|
||||
color: #fee715;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 800;
|
||||
margin-top: 4px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.lang-indicator .material-icons { font-size: 10px; }
|
||||
|
||||
.card-footer-preview {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.contact-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mini-contact-btn {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.mini-contact-btn.phone { background: rgba(255,255,255,0.1); color: white; border: 1px solid rgba(255,255,255,0.2); }
|
||||
.mini-contact-btn.wa { background: #25d366; color: white; box-shadow: 0 4px 12px rgba(37, 211, 102, 0.3); }
|
||||
|
||||
.mini-contact-btn .material-icons { font-size: 24px; }
|
||||
|
||||
.toggle-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nexus-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.nexus-switch input { opacity: 0; width: 0; height: 0; }
|
||||
|
||||
.nexus-switch .slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
transition: .4s;
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.nexus-switch .slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.nexus-switch input:checked + .slider { background-color: #fee715; }
|
||||
.nexus-switch input:checked + .slider:before { transform: translateX(20px); background-color: #101820; }
|
||||
|
||||
.preview-hint {
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.message {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.message.success { background: rgba(37, 211, 102, 0.1); color: #25d366; }
|
||||
.message.error { background: rgba(239, 68, 68, 0.1); color: #ef4444; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.admin-grid-layout { grid-template-columns: 1fr; }
|
||||
.preview-panel { order: -1; }
|
||||
}
|
||||
</style>
|
||||
716
frontend/src/views/AdminTaxis.vue
Normal file
716
frontend/src/views/AdminTaxis.vue
Normal file
@ -0,0 +1,716 @@
|
||||
<template>
|
||||
<div class="admin-taxis">
|
||||
<div class="header">
|
||||
<button class="back-link" @click="$router.push('/admin')">← Volver al Panel</button>
|
||||
<h1>Directorio de Taxis</h1>
|
||||
<button class="btn-primary" @click="openModal()">
|
||||
<span class="material-icons">add</span>
|
||||
Nuevo Taxi
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="loading">Cargando directorio...</div>
|
||||
|
||||
<div v-else class="taxis-list">
|
||||
<div v-if="taxis.length > 0" class="taxis-grid">
|
||||
<div v-for="taxi in taxis" :key="taxi.id" class="taxi-card">
|
||||
<div class="card-header">
|
||||
<div class="taxi-info">
|
||||
<div class="avatar">
|
||||
<img v-if="taxi.image_url" :src="getImageUrl(taxi.image_url)" alt="Taxi">
|
||||
<span v-else class="material-icons">local_taxi</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3>{{ taxi.owner_name }}</h3>
|
||||
<p class="plate">{{ taxi.license_plate }}</p>
|
||||
<p class="phone">{{ taxi.phone_number }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="btn-icon" @click="openModal(taxi)" title="Editar">
|
||||
<span class="material-icons">edit</span>
|
||||
</button>
|
||||
<button class="btn-icon delete" @click="deleteTaxi(taxi)" title="Eliminar">
|
||||
<span class="material-icons">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="info-row">
|
||||
<span class="label">Zona:</span>
|
||||
<span>{{ taxi.corregimiento }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Horario:</span>
|
||||
<span>{{ getShiftLabel(taxi.shift) }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="taxi.cooperative">
|
||||
<span class="label">Cooperativa:</span>
|
||||
<span>{{ taxi.cooperative }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Rating:</span>
|
||||
<span class="rating">{{ taxi.rating || 5.0 }} ⭐</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Inglés:</span>
|
||||
<span>{{ taxi.english_speaking ? 'Sí' : 'No' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Estado:</span>
|
||||
<span :class="taxi.is_active ? 'status-active' : 'status-inactive'">
|
||||
{{ taxi.is_active ? 'Activo' : 'Inactivo' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<span class="material-icons">local_taxi</span>
|
||||
<p>No hay taxis registrados en el directorio</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for Create/Edit -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>{{ editingTaxi ? 'Editar Taxi' : 'Nuevo Taxi' }}</h2>
|
||||
<button @click="closeModal">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form @submit.prevent="saveTaxi">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Nombre del Conductor *</label>
|
||||
<input v-model="taxiForm.owner_name" type="text" placeholder="Juan Pérez" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Teléfono *</label>
|
||||
<input v-model="taxiForm.phone_number" type="tel" placeholder="+507 1234-5678" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Placa del Vehículo *</label>
|
||||
<input v-model="taxiForm.license_plate" type="text" placeholder="CHI-1234" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Zona de Servicio *</label>
|
||||
<select v-model="taxiForm.corregimiento" required>
|
||||
<option value="">Seleccionar...</option>
|
||||
<option value="Boquete">Boquete</option>
|
||||
<option value="David - Boquete">David - Boquete</option>
|
||||
<option value="Boquete - David">Boquete - David</option>
|
||||
<option value="Aeropuerto - Boquete">Aeropuerto - Boquete</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Horario *</label>
|
||||
<select v-model="taxiForm.shift" required>
|
||||
<option value="">Seleccionar...</option>
|
||||
<option value="dia">Día</option>
|
||||
<option value="tarde">Tarde</option>
|
||||
<option value="noche">Noche</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Cooperativa</label>
|
||||
<input v-model="taxiForm.cooperative" type="text" placeholder="Cooperativa Boquete">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Rating (1-5)</label>
|
||||
<input v-model.number="taxiForm.rating" type="number" min="1" max="5" step="0.1" placeholder="5.0">
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input v-model="taxiForm.english_speaking" type="checkbox">
|
||||
<span>Habla Inglés</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input v-model="taxiForm.is_active" type="checkbox">
|
||||
<span>Activo en el directorio</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label>Foto del Conductor</label>
|
||||
<input type="file" @change="handleFileChange" accept="image/*">
|
||||
<small>Opcional - Foto para el directorio público</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error-text">{{ error }}</p>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-secondary" @click="closeModal">Cancelar</button>
|
||||
<button type="submit" class="btn-primary" :disabled="isSaving">
|
||||
{{ isSaving ? 'Guardando...' : 'Guardar' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import { apiClient, API_URL } from '@/services/apiClient'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const taxis = ref<any[]>([])
|
||||
const showModal = ref(false)
|
||||
const editingTaxi = ref<any>(null)
|
||||
const isSaving = ref(false)
|
||||
const error = ref('')
|
||||
const photoFile = ref<File | null>(null)
|
||||
|
||||
const taxiForm = reactive({
|
||||
owner_name: '',
|
||||
phone_number: '',
|
||||
license_plate: '',
|
||||
corregimiento: '',
|
||||
shift: '',
|
||||
cooperative: '',
|
||||
rating: 5.0,
|
||||
english_speaking: false,
|
||||
is_active: true
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadTaxis()
|
||||
})
|
||||
|
||||
async function loadTaxis() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await apiClient.get('/api/taxis', {
|
||||
params: { is_active: undefined } // Get all taxis
|
||||
})
|
||||
taxis.value = response.data
|
||||
} catch (e) {
|
||||
console.error('Error loading taxis:', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(taxi?: any) {
|
||||
if (taxi) {
|
||||
editingTaxi.value = taxi
|
||||
Object.assign(taxiForm, {
|
||||
owner_name: taxi.owner_name,
|
||||
phone_number: taxi.phone_number,
|
||||
license_plate: taxi.license_plate,
|
||||
corregimiento: taxi.corregimiento,
|
||||
shift: taxi.shift,
|
||||
cooperative: taxi.cooperative || '',
|
||||
rating: taxi.rating || 5.0,
|
||||
english_speaking: taxi.english_speaking || false,
|
||||
is_active: taxi.is_active
|
||||
})
|
||||
} else {
|
||||
editingTaxi.value = null
|
||||
Object.assign(taxiForm, {
|
||||
owner_name: '',
|
||||
phone_number: '',
|
||||
license_plate: '',
|
||||
corregimiento: '',
|
||||
shift: '',
|
||||
cooperative: '',
|
||||
rating: 5.0,
|
||||
english_speaking: false,
|
||||
is_active: true
|
||||
})
|
||||
}
|
||||
photoFile.value = null
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingTaxi.value = null
|
||||
photoFile.value = null
|
||||
}
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files && target.files[0]) {
|
||||
photoFile.value = target.files[0]
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTaxi() {
|
||||
isSaving.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('owner_name', taxiForm.owner_name)
|
||||
formData.append('phone_number', taxiForm.phone_number)
|
||||
formData.append('license_plate', taxiForm.license_plate)
|
||||
formData.append('corregimiento', taxiForm.corregimiento)
|
||||
formData.append('shift', taxiForm.shift)
|
||||
formData.append('rating', String(taxiForm.rating))
|
||||
formData.append('english_speaking', String(taxiForm.english_speaking))
|
||||
formData.append('is_active', String(taxiForm.is_active))
|
||||
|
||||
if (taxiForm.cooperative) formData.append('cooperative', taxiForm.cooperative)
|
||||
if (photoFile.value) formData.append('image', photoFile.value)
|
||||
|
||||
if (editingTaxi.value) {
|
||||
// Update existing taxi
|
||||
await apiClient.put(`/api/taxis/${editingTaxi.value.id}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
} else {
|
||||
// Create new taxi
|
||||
await apiClient.post('/api/taxis', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
closeModal()
|
||||
await loadTaxis()
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.detail || 'Error al guardar el taxi'
|
||||
console.error('Error saving taxi:', e)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTaxi(taxi: any) {
|
||||
if (!confirm(`¿Eliminar a ${taxi.owner_name} del directorio?`)) return
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/api/taxis/${taxi.id}`)
|
||||
await loadTaxis()
|
||||
} catch (e) {
|
||||
alert('Error al eliminar el taxi')
|
||||
console.error('Error deleting taxi:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function getShiftLabel(shift: string) {
|
||||
const labels: Record<string, string> = {
|
||||
'dia': 'Día',
|
||||
'tarde': 'Tarde',
|
||||
'noche': 'Noche'
|
||||
}
|
||||
return labels[shift] || shift
|
||||
}
|
||||
|
||||
function getImageUrl(path: string) {
|
||||
if (path.startsWith('http')) return path
|
||||
return `${API_URL}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-taxis {
|
||||
padding: 24px;
|
||||
background: var(--bg-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: var(--hover-bg);
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
h1 {
|
||||
flex: 1;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading, .empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state .material-icons {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.taxis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.taxi-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.taxi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px var(--shadow);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.taxi-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar .material-icons {
|
||||
font-size: 32px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.taxi-info h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.plate {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: var(--bg-secondary);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
font-size: 0.9rem;
|
||||
margin: 4px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.phone {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-icon.delete:hover {
|
||||
background: #fee;
|
||||
border-color: #f44;
|
||||
color: #f44;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.info-row .label {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rating {
|
||||
color: #fee715;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--card-bg);
|
||||
border-radius: 16px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-header button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.modal-header button:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-group.checkbox-group {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-group.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="tel"],
|
||||
.form-group input[type="number"],
|
||||
.form-group select {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.form-group input[type="file"] {
|
||||
padding: 8px;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #f44;
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
background: #fee;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.taxis-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
88
frontend/src/views/AuthView.vue
Normal file
88
frontend/src/views/AuthView.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import LoginForm from '@/components/auth/LoginForm.vue'
|
||||
import RegisterForm from '@/components/auth/RegisterForm.vue'
|
||||
|
||||
const isLogin = ref(true)
|
||||
|
||||
const toggleAuth = () => {
|
||||
isLogin.value = !isLogin.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-view">
|
||||
<div class="auth-box">
|
||||
<div class="auth-header">
|
||||
<img src="/icon-192.png" alt="SIBU Logo" class="logo" />
|
||||
<h1 class="brand-name">SIBU</h1>
|
||||
<p class="brand-tagline">Moviendo a tu comunidad</p>
|
||||
</div>
|
||||
|
||||
<transition name="fade" mode="out-in">
|
||||
<LoginForm v-if="isLogin" :on-toggle="toggleAuth" />
|
||||
<RegisterForm v-else :on-toggle="toggleAuth" :on-success="() => isLogin = true" />
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-view {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #42b983 0%, #2c3e50 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-box {
|
||||
background: var(--card-bg);
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
padding: 40px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.brand-tagline {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
93
frontend/src/views/BusStopDetailsView.vue
Normal file
93
frontend/src/views/BusStopDetailsView.vue
Normal file
@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBusStopStore } from '@/stores/busStop'
|
||||
import { analyticsService } from '@/services/analyticsService'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const route = useRoute()
|
||||
const busStopStore = useBusStopStore()
|
||||
|
||||
onMounted(async () => {
|
||||
const stopId = route.params.id as string
|
||||
if (stopId) {
|
||||
await busStopStore.loadBusStopById(stopId)
|
||||
if (busStopStore.selectedStop) {
|
||||
analyticsService.logEvent({
|
||||
event_name: 'stop_selected',
|
||||
item_id: busStopStore.selectedStop.name,
|
||||
properties: { stop_id: stopId }
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bus-stop-details-view">
|
||||
<div v-if="busStopStore.isLoading">
|
||||
<p>{{ t('busStop.loadingDetails') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="busStopStore.error">
|
||||
<p>{{ t('common.error') }}: {{ busStopStore.error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="busStopStore.selectedStop">
|
||||
<h1>{{ busStopStore.selectedStop.name }}</h1>
|
||||
<p v-if="busStopStore.selectedStop.address">{{ busStopStore.selectedStop.address }}</p>
|
||||
<p v-if="busStopStore.selectedStop.city">{{ busStopStore.selectedStop.city }}</p>
|
||||
|
||||
<div class="amenities">
|
||||
<h3>{{ t('busStop.amenities') }}</h3>
|
||||
<ul>
|
||||
<li v-if="busStopStore.selectedStop.has_shelter">{{ t('busStop.shelter') }}</li>
|
||||
<li v-if="busStopStore.selectedStop.has_seating">{{ t('busStop.seating') }}</li>
|
||||
<li v-if="busStopStore.selectedStop.is_accessible">{{ t('busStop.accessible') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bus-stop-details-view {
|
||||
padding: 1rem;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100%;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.bus-stop-details-view h1,
|
||||
.bus-stop-details-view h3 {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bus-stop-details-view p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.amenities {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.amenities ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.amenities li {
|
||||
padding: 0.5rem 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
485
frontend/src/views/BusinessDetailsView.vue
Normal file
485
frontend/src/views/BusinessDetailsView.vue
Normal file
@ -0,0 +1,485 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { businessService } from '@/services/businessService'
|
||||
import { couponsService } from '@/services/couponsService'
|
||||
import { API_URL } from '@/services/apiClient'
|
||||
import type { Business, Coupon } from '@/types'
|
||||
import FavoriteButton from '@/components/FavoriteButton.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const business = ref<Business | null>(null)
|
||||
const coupons = ref<Coupon[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
import { analyticsService } from '@/services/analyticsService'
|
||||
|
||||
onMounted(async () => {
|
||||
const id = route.params.id as string
|
||||
try {
|
||||
const [bizData, allCoupons] = await Promise.all([
|
||||
businessService.getBusiness(id),
|
||||
couponsService.getAllCoupons({ active_only: true })
|
||||
])
|
||||
business.value = bizData
|
||||
// Filter coupons for this business
|
||||
coupons.value = allCoupons.filter(c => c.business_id === id)
|
||||
|
||||
analyticsService.logEvent({
|
||||
event_name: 'screen_view',
|
||||
screen_name: 'BusinessDetails',
|
||||
item_id: bizData.name,
|
||||
properties: { business_id: id }
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to load business details', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function getImageUrl(path: string | null | undefined) {
|
||||
if (!path) return '/default-business.jpg'
|
||||
if (path.startsWith('http')) return path
|
||||
return `${API_URL}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
}
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
function handleDirections() {
|
||||
analyticsService.logEvent({
|
||||
event_name: 'promo_click',
|
||||
item_id: 'directions_' + business.value?.name,
|
||||
properties: {
|
||||
business_id: business.value?.id,
|
||||
action: 'get_directions'
|
||||
}
|
||||
})
|
||||
window.open(`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(business.value?.address || '')}`, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-details-view" v-if="!isLoading && business">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-section">
|
||||
<img :src="getImageUrl(business.image_url)" alt="Business Image" class="hero-image" />
|
||||
<div class="hero-overlay"></div>
|
||||
|
||||
<button class="back-floating" @click="goBack">
|
||||
<span class="material-icons">arrow_back</span>
|
||||
</button>
|
||||
|
||||
<div class="fav-floating">
|
||||
<FavoriteButton
|
||||
item-type="business"
|
||||
:item-id="business.id"
|
||||
:item-name="business.name"
|
||||
:item-image="business.image_url || undefined"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="hero-content">
|
||||
<div class="category-badge premium-font">{{ business.category }}</div>
|
||||
<h1 class="business-name premium-font">{{ business.name }}</h1>
|
||||
<div class="area-tag">
|
||||
<span class="material-icons">location_on</span>
|
||||
{{ business.area }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Content -->
|
||||
<div class="details-container">
|
||||
<div class="premium-story">
|
||||
<h2 class="premium-font">Explora Nuestra Historia Para Una Cocina Refinada Y Un Ambiente Atemporal</h2>
|
||||
<p>"Nuestra historia es una de crecimiento, exploración y recuerdos culinarios inolvidables, donde cada capítulo se sirve con elegancia."</p>
|
||||
</div>
|
||||
|
||||
<!-- Highlights Grid (Inspired by the frame) -->
|
||||
<div class="highlights-grid">
|
||||
<div class="highlight-item">
|
||||
<div class="highlight-header">
|
||||
<h3 class="premium-font">Herencia Atemporal</h3>
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
<p>Platos de autor que evolucionan con inspiración y cultura local.</p>
|
||||
</div>
|
||||
|
||||
<div class="highlight-item">
|
||||
<div class="highlight-header">
|
||||
<h3 class="premium-font">Platos de Clase Mundial</h3>
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
<p>Experiencia gastronómica diseñada para deleitar los sentidos más exigentes.</p>
|
||||
</div>
|
||||
|
||||
<div class="highlight-item">
|
||||
<div class="highlight-header">
|
||||
<h3 class="premium-font">Emoción y Elegancia</h3>
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
<p>Veladas realzadas por el encanto atemporal de un ambiente exclusivo.</p>
|
||||
</div>
|
||||
|
||||
<div class="highlight-item">
|
||||
<div class="highlight-header">
|
||||
<h3 class="premium-font">Experiencia Inigualable</h3>
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
<p>Servicio personalizado desde un anfitrión dedicado para tu comodidad.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Section -->
|
||||
<div class="info-sections">
|
||||
<div class="info-card">
|
||||
<span class="material-icons">map</span>
|
||||
<div class="info-text">
|
||||
<h4>Dirección</h4>
|
||||
<p>{{ business.address }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<span class="material-icons">phone</span>
|
||||
<div class="info-text">
|
||||
<h4>Contacto</h4>
|
||||
<p>{{ business.phone || 'No disponible' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="business.social_media" class="info-card">
|
||||
<span class="material-icons">language</span>
|
||||
<div class="info-text">
|
||||
<h4>Redes Sociales</h4>
|
||||
<p>{{ business.social_media }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<span class="material-icons">directions</span>
|
||||
<div class="info-text">
|
||||
<h4>Cómo llegar</h4>
|
||||
<button class="track-directions-btn" @click="handleDirections">
|
||||
Ver mapa y ruta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Offers Section -->
|
||||
<div v-if="coupons.length > 0" class="offers-section">
|
||||
<h2 class="section-title premium-font">Ofertas Disponibles</h2>
|
||||
<div class="coupons-grid">
|
||||
<div v-for="coupon in coupons" :key="coupon.id" class="coupon-card-detail">
|
||||
<div class="coupon-header-flex">
|
||||
<div class="coupon-discount">{{ coupon.discount_percentage }}% OFF</div>
|
||||
<FavoriteButton
|
||||
item-type="coupon"
|
||||
:item-id="coupon.id"
|
||||
:item-name="coupon.title"
|
||||
:item-image="coupon.image_url || undefined"
|
||||
/>
|
||||
</div>
|
||||
<div class="coupon-info">
|
||||
<h3>{{ coupon.title }}</h3>
|
||||
<p>{{ coupon.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isLoading" class="loading-full">
|
||||
<div class="loader"></div>
|
||||
<p>Cargando experiencia premium...</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.business-details-view {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.premium-font {
|
||||
font-family: 'Playfair Display', serif;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero-section {
|
||||
position: relative;
|
||||
height: 60vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.hero-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.8) 100%);
|
||||
}
|
||||
|
||||
.back-floating {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.fav-floating {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.coupon-header-flex {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
left: 40px;
|
||||
right: 40px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
background: var(--active-color);
|
||||
color: white;
|
||||
padding: 6px 16px;
|
||||
border-radius: 100px;
|
||||
display: inline-block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.business-name {
|
||||
font-size: 3.5rem;
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.area-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 1.2rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.details-container {
|
||||
max-width: 1000px;
|
||||
margin: -40px auto 0;
|
||||
position: relative;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 30px 30px 0 0;
|
||||
padding: 60px 40px;
|
||||
box-shadow: 0 -20px 40px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.premium-story {
|
||||
text-align: center;
|
||||
max-width: 700px;
|
||||
margin: 0 auto 60px;
|
||||
}
|
||||
|
||||
.premium-story h2 {
|
||||
font-size: 2.2rem;
|
||||
margin-bottom: 24px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.premium-story p {
|
||||
font-size: 1.2rem;
|
||||
font-style: italic;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Highlights Grid */
|
||||
.highlights-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 40px;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
.highlight-item {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.highlight-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.highlight-header h3 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.highlight-item p {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Info Cards */
|
||||
.info-sections {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: var(--bg-secondary);
|
||||
padding: 24px;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-card .material-icons {
|
||||
color: var(--active-color);
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.info-text h4 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.info-text p {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.track-directions-btn {
|
||||
background: var(--active-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Offers Section */
|
||||
.offers-section {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.coupons-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.coupon-card-detail {
|
||||
background: var(--card-bg);
|
||||
border: 2px dashed var(--active-color);
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.coupon-discount {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 900;
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
.coupon-info h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.coupon-info p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Loader */
|
||||
.loading-full {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 3px solid var(--bg-secondary);
|
||||
border-top-color: var(--active-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-section { height: 50vh; }
|
||||
.business-name { font-size: 2.5rem; }
|
||||
.highlights-grid { grid-template-columns: 1fr; }
|
||||
.details-container { padding: 40px 20px; }
|
||||
.hero-content { left: 20px; bottom: 30px; }
|
||||
}
|
||||
</style>
|
||||
646
frontend/src/views/CouponsView.vue
Normal file
646
frontend/src/views/CouponsView.vue
Normal file
@ -0,0 +1,646 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useCouponStore } from '@/stores/coupon'
|
||||
import { API_URL } from '@/services/apiClient'
|
||||
import type { Coupon } from '@/types'
|
||||
import FavoriteButton from '@/components/FavoriteButton.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const couponStore = useCouponStore()
|
||||
|
||||
const showRedeemModal = ref(false)
|
||||
const selectedCoupon = ref<Coupon | null>(null)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref('Todas')
|
||||
const showFilterSheet = ref(false)
|
||||
const categories = ['Todas', 'Restaurante', 'Turismo', 'Bebidas', 'Comercio']
|
||||
|
||||
onMounted(() => {
|
||||
couponStore.loadCoupons()
|
||||
})
|
||||
|
||||
const filteredCoupons = computed(() => {
|
||||
return couponStore.coupons.filter(c => {
|
||||
const matchesSearch = c.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
c.business_name?.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
const matchesCategory = selectedCategory.value === 'Todas' || c.category === selectedCategory.value
|
||||
return matchesSearch && matchesCategory
|
||||
})
|
||||
})
|
||||
|
||||
function getImageUrl(path: string | null | undefined) {
|
||||
if (!path) return '/default-coupon.png'
|
||||
if (path.startsWith('http')) return path
|
||||
return `${API_URL}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
}
|
||||
|
||||
|
||||
import { analyticsService } from '@/services/analyticsService'
|
||||
|
||||
function openCoupon(coupon: Coupon) {
|
||||
selectedCoupon.value = coupon
|
||||
showRedeemModal.value = true
|
||||
analyticsService.logEvent({
|
||||
event_name: 'promo_view',
|
||||
item_id: coupon.title,
|
||||
properties: { coupon_id: coupon.id, business: coupon.business_name }
|
||||
})
|
||||
}
|
||||
|
||||
function handleDirections() {
|
||||
if (!selectedCoupon.value) return
|
||||
analyticsService.logEvent({
|
||||
event_name: 'promo_click',
|
||||
item_id: 'directions_' + selectedCoupon.value.business_name,
|
||||
properties: {
|
||||
coupon_id: selectedCoupon.value.id,
|
||||
action: 'get_directions'
|
||||
}
|
||||
})
|
||||
window.open(`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(selectedCoupon.value.business_address || selectedCoupon.value.business_name || '')}`, '_blank')
|
||||
}
|
||||
|
||||
function getCategoryIcon(category?: string | null) {
|
||||
const cat = category?.toLowerCase() || ''
|
||||
if (cat.includes('restaurante')) return 'restaurant'
|
||||
if (cat.includes('turismo')) return 'landscape'
|
||||
if (cat.includes('bebida')) return 'local_bar'
|
||||
if (cat.includes('comercio')) return 'store'
|
||||
return 'local_offer'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="coupons-view">
|
||||
<header class="mobile-header">
|
||||
<button class="icon-btn-back" @click="$router.back()">
|
||||
<span class="material-icons">arrow_back</span>
|
||||
</button>
|
||||
<div class="header-center">
|
||||
<span class="material-icons brand-icon">local_offer</span>
|
||||
<h1>{{ t('coupons.title') }}</h1>
|
||||
</div>
|
||||
<div class="header-right"></div>
|
||||
</header>
|
||||
|
||||
<div class="search-section">
|
||||
<div class="search-bar-rounded">
|
||||
<span class="material-icons search-icon">search</span>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('coupons.searchPlaceholder')"
|
||||
class="search-input"
|
||||
>
|
||||
</div>
|
||||
<button class="filter-btn-square" @click="showFilterSheet = true">
|
||||
<span class="material-icons">tune</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="offers-stats">
|
||||
<span class="stat-dot"></span>
|
||||
<span class="stat-label">{{ t('coupons.offersCount', { count: filteredCoupons.length }) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="couponStore.isLoading" class="loading-container">
|
||||
<span class="material-icons spin">refresh</span>
|
||||
<p>{{ t('coupons.loadingCoupons') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="couponStore.error" class="error-container">
|
||||
<span class="material-icons">error_outline</span>
|
||||
<p>{{ t('common.error') }}: {{ couponStore.error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredCoupons.length === 0" class="empty-container">
|
||||
<span class="material-icons">search_off</span>
|
||||
<p>No se encontraron resultados para tu búsqueda.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="coupons-grid-new">
|
||||
<div
|
||||
v-for="coupon in filteredCoupons"
|
||||
:key="coupon.id"
|
||||
class="offer-card-new"
|
||||
@click="openCoupon(coupon)"
|
||||
>
|
||||
<div class="offer-image-wrapper">
|
||||
<img :src="getImageUrl(coupon.image_url)" :alt="coupon.title" class="offer-img">
|
||||
<div class="status-badge" :class="{ 'tmr': coupon.title.toLowerCase().includes('mañana') || (coupon.description?.toLowerCase().includes('mañana') ?? false) }">
|
||||
<span class="material-icons">schedule</span>
|
||||
{{ coupon.title.toLowerCase().includes('mañana') ? t('coupons.tomorrow') : t('coupons.active') }}
|
||||
</div>
|
||||
<div class="favorite-button-wrapper">
|
||||
<FavoriteButton
|
||||
item-type="coupon"
|
||||
:item-id="coupon.id"
|
||||
:item-name="coupon.title"
|
||||
:item-image="coupon.image_url || undefined"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="offer-content">
|
||||
<h3 class="offer-title">{{ coupon.business_name || 'Restaurante' }}</h3>
|
||||
<p class="offer-benefit">{{ coupon.title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Filter Sheet -->
|
||||
<div v-if="showFilterSheet" class="bottom-sheet-overlay" @click.self="showFilterSheet = false">
|
||||
<div class="bottom-sheet">
|
||||
<div class="sheet-handle"></div>
|
||||
<div class="sheet-header">
|
||||
<h3>{{ t('coupons.filterByCategory') }}</h3>
|
||||
</div>
|
||||
<div class="sheet-body">
|
||||
<div v-for="cat in categories" :key="cat" class="filter-option" @click="selectedCategory = cat; showFilterSheet = false">
|
||||
<span class="material-icons">{{ getCategoryIcon(cat) }}</span>
|
||||
<span>{{ cat }}</span>
|
||||
<span v-if="selectedCategory === cat" class="material-icons check">check_circle</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sheet-footer">
|
||||
<button class="apply-btn-full" @click="showFilterSheet = false">
|
||||
{{ t('coupons.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Modal -->
|
||||
<div v-if="showRedeemModal" class="modal-overlay-new" @click.self="showRedeemModal = false">
|
||||
<div class="detail-modal-new" v-if="selectedCoupon">
|
||||
<div class="modal-header-new">
|
||||
<h3>{{ t('coupons.offerDetails') }}</h3>
|
||||
<button class="close-btn-round" @click="showRedeemModal = false">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-scroll-body">
|
||||
<div class="modal-hero-image">
|
||||
<img :src="getImageUrl(selectedCoupon.image_url)" alt="Header Image">
|
||||
</div>
|
||||
|
||||
<div class="modal-info-section">
|
||||
<h2 class="modal-business-title">{{ selectedCoupon.business_name }}</h2>
|
||||
|
||||
<div class="benefit-highlight-box">
|
||||
<span class="material-icons icon-label">local_offer</span>
|
||||
<span>{{ selectedCoupon.title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-block">
|
||||
<div class="block-header">
|
||||
<span class="material-icons icon-desc">description</span>
|
||||
<h4>{{ t('coupons.description') }}</h4>
|
||||
</div>
|
||||
<p class="block-text">{{ selectedCoupon.description || 'Sin descripción adicional.' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-block">
|
||||
<div class="block-header">
|
||||
<span class="material-icons icon-date">calendar_today</span>
|
||||
<h4>{{ t('coupons.validity') }}</h4>
|
||||
</div>
|
||||
<div class="validity-badge">
|
||||
{{ t('coupons.validUntil') }} {{ selectedCoupon.valid_until || '31/1/2026' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedCoupon.category" class="info-block">
|
||||
<div class="block-header">
|
||||
<span class="material-icons icon-cat">category</span>
|
||||
<h4>{{ t('coupons.category') }}</h4>
|
||||
</div>
|
||||
<div class="category-badge-simple">
|
||||
{{ selectedCoupon.category.toLowerCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer-new">
|
||||
<button class="location-btn-green" @click="handleDirections">
|
||||
<span class="material-icons">place</span>
|
||||
{{ t('coupons.viewLocation') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.coupons-view {
|
||||
padding: 0;
|
||||
background: var(--bg-primary);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Mobile Header */
|
||||
.mobile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-primary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-center h1 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
color: #fee715;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.icon-btn-back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-right { width: 40px; }
|
||||
|
||||
/* Search and Filters */
|
||||
.search-section {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.search-bar-rounded {
|
||||
flex: 1;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-bar-rounded:focus-within {
|
||||
border-color: #fee715;
|
||||
}
|
||||
|
||||
.search-icon { color: var(--text-secondary); }
|
||||
.search-input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.filter-btn-square {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.offers-stats {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #fee715;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Grid and Cards */
|
||||
.coupons-grid-new {
|
||||
padding: 0 16px 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.offer-card-new {
|
||||
background: var(--card-bg);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
|
||||
.offer-image-wrapper {
|
||||
position: relative;
|
||||
aspect-ratio: 1/1;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.offer-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: #166534;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-badge.tmr { background: #f97316; }
|
||||
.status-badge .material-icons { font-size: 10px; }
|
||||
|
||||
.favorite-button-wrapper {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.offer-content {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.offer-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.offer-benefit {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Bottom Sheet */
|
||||
.bottom-sheet-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.bottom-sheet {
|
||||
background: var(--card-bg);
|
||||
width: 100%;
|
||||
border-radius: 24px 24px 0 0;
|
||||
padding: 12px 20px 30px;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.sheet-handle {
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.sheet-header h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-option .material-icons { color: var(--text-secondary); }
|
||||
.filter-option span { font-weight: 600; color: var(--text-primary); }
|
||||
.filter-option .check { color: #fee715; margin-left: auto; }
|
||||
|
||||
.sheet-footer { margin-top: 24px; }
|
||||
.apply-btn-full {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: #fee715;
|
||||
color: #101820;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Detail Modal New */
|
||||
.modal-overlay-new {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-modal-new {
|
||||
background: var(--card-bg);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
height: 85vh;
|
||||
border-radius: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-header-new {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header-new h3 { font-size: 1rem; font-weight: 800; }
|
||||
.close-btn-round {
|
||||
background: var(--bg-secondary);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-scroll-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-hero-image img {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.modal-info-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-business-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.benefit-highlight-box {
|
||||
background: #fefce8; /* Light yellow in light mode */
|
||||
border: 1px solid #fee715;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 700;
|
||||
color: #854d0e;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dark .benefit-highlight-box {
|
||||
background: rgba(254, 231, 21, 0.1);
|
||||
color: #fee715;
|
||||
}
|
||||
|
||||
.icon-label { color: #facc15; }
|
||||
|
||||
.info-block {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.block-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.block-header h4 { font-size: 0.9rem; font-weight: 700; color: var(--text-primary); }
|
||||
.block-header .material-icons { font-size: 18px; color: var(--text-secondary); }
|
||||
|
||||
.block-text { font-size: 0.9rem; color: var(--text-secondary); line-height: 1.4; }
|
||||
|
||||
.validity-badge {
|
||||
display: inline-block;
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.dark .validity-badge { background: rgba(22, 101, 52, 0.2); color: #4ade80; }
|
||||
|
||||
.category-badge-simple {
|
||||
display: inline-block;
|
||||
background: #fefce8;
|
||||
color: #854d0e;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.modal-footer-new {
|
||||
padding: 20px;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.location-btn-green {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
font-weight: 800;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.loading-container, .error-container, .empty-container {
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { 100% { transform: rotate(360deg); } }
|
||||
</style>
|
||||
|
||||
588
frontend/src/views/DiscoverView.vue
Normal file
588
frontend/src/views/DiscoverView.vue
Normal file
@ -0,0 +1,588 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { businessService } from '@/services/businessService';
|
||||
import { API_URL } from '@/services/apiClient';
|
||||
import type { Business } from '@/types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import FavoriteButton from '@/components/FavoriteButton.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const businesses = ref<Business[]>([]);
|
||||
const isLoading = ref(true);
|
||||
const selectedArea = ref('Todas');
|
||||
const selectedCategory = ref('Todas');
|
||||
|
||||
import { analyticsService } from '@/services/analyticsService';
|
||||
|
||||
onMounted(async () => {
|
||||
analyticsService.logEvent({ event_name: 'screen_view', screen_name: 'Discover' });
|
||||
try {
|
||||
businesses.value = await businessService.getAllBusinesses();
|
||||
} catch (error) {
|
||||
console.error('Error loading tourist spots:', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function handleExplore(biz: Business) {
|
||||
analyticsService.logEvent({
|
||||
event_name: 'promo_click',
|
||||
item_id: biz.name,
|
||||
properties: { business_id: biz.id }
|
||||
});
|
||||
router.push('/business/' + biz.id);
|
||||
}
|
||||
|
||||
const filteredBusinesses = computed(() => {
|
||||
let filtered = businesses.value;
|
||||
|
||||
if (selectedArea.value !== 'Todas') {
|
||||
filtered = filtered.filter(b => b.area === selectedArea.value);
|
||||
}
|
||||
|
||||
if (selectedCategory.value !== 'Todas') {
|
||||
filtered = filtered.filter(b => b.category === selectedCategory.value);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const categories = computed<string[]>(() => {
|
||||
const cats = new Set(businesses.value.map(b => b.category).filter(Boolean) as string[]);
|
||||
return ['Todas', ...Array.from(cats)];
|
||||
});
|
||||
|
||||
function getImageUrl(path: string | null | undefined) {
|
||||
if (!path) return '/default-business.jpg';
|
||||
if (path.startsWith('http')) return path;
|
||||
return `${API_URL}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
}
|
||||
|
||||
function getCategoryIcon(category: string) {
|
||||
const icons: Record<string, string> = {
|
||||
'Restaurante': 'restaurant',
|
||||
'Turismo': 'landscape',
|
||||
'Bebidas': 'local_bar',
|
||||
'Comercio': 'store',
|
||||
'Hotel': 'hotel',
|
||||
'Café': 'local_cafe'
|
||||
};
|
||||
return icons[category] || 'place';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="discover-view">
|
||||
<!-- Compact Glass Header -->
|
||||
<header class="premium-header">
|
||||
<div class="header-glass-card">
|
||||
<div class="header-icon-box">
|
||||
<span class="material-icons">explore</span>
|
||||
<div class="icon-pulse"></div>
|
||||
</div>
|
||||
<div class="header-text-box">
|
||||
<h1 class="gradient-text">{{ t('discover.title') }}</h1>
|
||||
<p class="subtitle">{{ t('discover.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Integrated High-Tech Filters -->
|
||||
<div class="filters-panel">
|
||||
<div class="glass-filters">
|
||||
<div class="filter-item">
|
||||
<div class="filter-label">
|
||||
<span class="material-icons">location_on</span>
|
||||
<span>Región</span>
|
||||
</div>
|
||||
<div class="custom-select-box">
|
||||
<select v-model="selectedArea" class="modern-select">
|
||||
<option value="Todas">{{ t('discover.allAreas') }}</option>
|
||||
<option value="Boquete">Boquete</option>
|
||||
<option value="Dolega">Dolega</option>
|
||||
<option value="David">David</option>
|
||||
</select>
|
||||
<span class="material-icons">expand_more</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-divider"></div>
|
||||
|
||||
<div class="filter-item">
|
||||
<div class="filter-label">
|
||||
<span class="material-icons">category</span>
|
||||
<span>Categoría</span>
|
||||
</div>
|
||||
<div class="custom-select-box">
|
||||
<select v-model="selectedCategory" class="modern-select">
|
||||
<option v-for="cat in categories" :key="cat" :value="cat">
|
||||
{{ cat }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="material-icons">expand_more</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="discover-main">
|
||||
<!-- Loading Experience -->
|
||||
<div v-if="isLoading" class="loading-container">
|
||||
<div class="nexus-loader">
|
||||
<div class="nexus-dot"></div>
|
||||
<div class="nexus-ring"></div>
|
||||
</div>
|
||||
<p class="loading-text">Sincronizando con SIBU...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="filteredBusinesses.length === 0" class="empty-nexus">
|
||||
<div class="empty-nexus-box">
|
||||
<span class="material-icons">search_off</span>
|
||||
<h3>Sin resultados</h3>
|
||||
<p>La búsqueda no devolvió datos en esta frecuencia.</p>
|
||||
<button class="reboot-btn" @click="selectedArea = 'Todas'; selectedCategory = 'Todas'">
|
||||
REINICIAR SENSORES
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Business Grid Premium -->
|
||||
<TransitionGroup
|
||||
v-else
|
||||
name="stagger-list"
|
||||
tag="div"
|
||||
class="premium-business-grid"
|
||||
>
|
||||
<div v-for="(biz, index) in filteredBusinesses"
|
||||
:key="biz.id"
|
||||
class="nexus-card"
|
||||
@click="handleExplore(biz)"
|
||||
:style="{ '--order': index }"
|
||||
>
|
||||
<div class="nexus-card-inner">
|
||||
<div class="nexus-image-container">
|
||||
<img :src="getImageUrl(biz.image_url)" alt="" class="nexus-img">
|
||||
<div class="nexus-overlay-gradient"></div>
|
||||
|
||||
<!-- Floating Badges -->
|
||||
<div class="nexus-badge category">
|
||||
<span class="material-icons">{{ getCategoryIcon(biz.category || '') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="nexus-fav">
|
||||
<FavoriteButton
|
||||
item-type="business"
|
||||
:item-id="biz.id"
|
||||
:item-name="biz.name"
|
||||
:item-image="biz.image_url || undefined"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nexus-card-details">
|
||||
<h3 class="nexus-biz-name">{{ biz.name }}</h3>
|
||||
<div class="nexus-biz-meta">
|
||||
<span class="area"><span class="material-icons">near_me</span>{{ biz.area }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.discover-view {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
padding: 20px 16px 150px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Contenido directo */
|
||||
.premium-header,
|
||||
.filters-panel,
|
||||
.discover-main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Tarjetas nitidas */
|
||||
.header-glass-card,
|
||||
.glass-filters,
|
||||
.nexus-card-inner {
|
||||
background: var(--card-bg) !important;
|
||||
backdrop-filter: none !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
}
|
||||
|
||||
/* Premium Header */
|
||||
.premium-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-glass-card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.header-icon-box {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: var(--active-bg);
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-icon-box .material-icons {
|
||||
color: var(--active-color);
|
||||
font-size: 28px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.icon-pulse {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 18px;
|
||||
border: 2px solid var(--active-color);
|
||||
animation: pulse-out 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-out {
|
||||
0% { transform: scale(1); opacity: 0.5; }
|
||||
100% { transform: scale(1.5); opacity: 0; }
|
||||
}
|
||||
|
||||
.header-text-box h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 900;
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Unified Filters */
|
||||
.filters-panel {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.glass-filters {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.filter-label .material-icons {
|
||||
font-size: 14px;
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
.custom-select-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modern-select {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
padding-right: 24px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.custom-select-box .material-icons {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.filter-divider {
|
||||
width: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* Premium Grid */
|
||||
.premium-business-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.premium-business-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.premium-business-grid {
|
||||
grid-template-columns: repeat(2, 1fr); /* Mantenemos 2 en móvil para aprovechar el espacio nexus */
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.nexus-card {
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.nexus-card-inner {
|
||||
background: var(--card-bg);
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.nexus-card:hover .nexus-card-inner {
|
||||
transform: translateY(-8px);
|
||||
border-color: var(--active-color);
|
||||
box-shadow: 0 15px 30px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.nexus-image-container {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nexus-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.nexus-card:hover .nexus-img {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.nexus-overlay-gradient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(15, 23, 42, 0.8) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
.nexus-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--active-bg);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--active-color);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.nexus-fav {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.nexus-card-details {
|
||||
padding: 16px;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.nexus-biz-name {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nexus-biz-meta {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.area {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.area .material-icons {
|
||||
font-size: 14px;
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
/* Loading & Empty States */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100px 0;
|
||||
}
|
||||
|
||||
.nexus-loader {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.nexus-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--active-color);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 0 20px var(--active-color);
|
||||
}
|
||||
|
||||
.nexus-ring {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 4px solid var(--active-bg);
|
||||
border-top-color: var(--active-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.empty-nexus {
|
||||
padding: 60px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-nexus-box {
|
||||
background: var(--card-bg);
|
||||
border-radius: 30px;
|
||||
padding: 40px;
|
||||
border: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.empty-nexus-box .material-icons {
|
||||
font-size: 48px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.reboot-btn {
|
||||
margin-top: 24px;
|
||||
background: var(--active-color);
|
||||
color: #101820;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.stagger-list-enter-active {
|
||||
transition: all 0.5s ease;
|
||||
transition-delay: calc(0.1s * var(--order));
|
||||
}
|
||||
|
||||
.stagger-list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header-glass-card {
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-icon-box {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.header-text-box h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
403
frontend/src/views/DriverDashboard.vue
Normal file
403
frontend/src/views/DriverDashboard.vue
Normal file
@ -0,0 +1,403 @@
|
||||
<template>
|
||||
<div class="driver-dashboard">
|
||||
<div class="dashboard-header">
|
||||
<div class="user-welcome">
|
||||
<h1>Hola, {{ userName }}</h1>
|
||||
<p>Panel de Control de Transportista</p>
|
||||
</div>
|
||||
<button class="logout-btn" @click="handleLogout">Cerrar Sesión</button>
|
||||
</div>
|
||||
|
||||
<!-- Verification Status Banner -->
|
||||
<div v-if="!isVerified" class="status-banner pending">
|
||||
<span class="material-icons">hourglass_empty</span>
|
||||
<div class="banner-text">
|
||||
<h3>Tu cuenta está pendiente de verificación</h3>
|
||||
<p>Un administrador revisará tus documentos pronto. Mientras tanto, algunas funciones están limitadas.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="status-banner verified">
|
||||
<span class="material-icons">verified</span>
|
||||
<div class="banner-text">
|
||||
<h3>Cuenta Verificada</h3>
|
||||
<p>¡Felicidades! Tu cuenta está activa y puedes operar en el sistema.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- Profile Status Card -->
|
||||
<div class="dashboard-card">
|
||||
<h3>Mi Perfil</h3>
|
||||
<div class="profile-preview">
|
||||
<div class="info-row">
|
||||
<label>Cédula:</label>
|
||||
<span>{{ driverProfile?.cedula || '---' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<label>Vehículo:</label>
|
||||
<span>{{ (driverProfile?.vehicle_type || '---').toUpperCase() }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<label>Placa:</label>
|
||||
<span>{{ driverProfile?.license_plate || '---' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="secondary-btn" @click="editProfile" :disabled="!isVerified">Editar Perfil</button>
|
||||
</div>
|
||||
|
||||
<!-- Real-time Availability Card -->
|
||||
<div class="dashboard-card" :class="{ 'active-service': isInService }">
|
||||
<div class="card-header-flex">
|
||||
<h3>Estado en Tiempo Real</h3>
|
||||
<div class="status-indicator" :class="{ 'online': isInService }"></div>
|
||||
</div>
|
||||
|
||||
<div class="service-controls">
|
||||
<p v-if="!isInService" class="service-hint">Activa tu ubicación para que los pasajeros puedan verte en el mapa.</p>
|
||||
<p v-else class="service-hint active">Tu ubicación se está transmitiendo en tiempo real.</p>
|
||||
|
||||
<button
|
||||
@click="toggleService"
|
||||
:class="isInService ? 'service-btn stop' : 'service-btn start'"
|
||||
:disabled="!isVerified || isLocating"
|
||||
>
|
||||
<span class="material-icons">{{ isInService ? 'location_off' : 'location_on' }}</span>
|
||||
{{ isInService ? 'Detener Servicio' : 'Iniciar Servicio' }}
|
||||
</button>
|
||||
|
||||
<div v-if="isLocating" class="locating-spinner">
|
||||
<span class="material-icons spin">refresh</span>
|
||||
Obteniendo ubicación...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authService } from '@/services/authService'
|
||||
import { telemetryService } from '@/services/telemetryService'
|
||||
|
||||
const router = useRouter()
|
||||
const userName = localStorage.getItem('user_name') || 'Conductor'
|
||||
const isVerified = ref(false)
|
||||
const driverProfile = ref<any>(null)
|
||||
|
||||
// Service tracking state
|
||||
const isInService = ref(false)
|
||||
const isLocating = ref(false)
|
||||
const watchId = ref<number | null>(null)
|
||||
const lastUpdate = ref<number>(0)
|
||||
const minUpdateInterval = 10000 // 10 seconds
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchStatus()
|
||||
|
||||
// Restore state if was in service (optional, simpler for now to start off)
|
||||
const savedService = localStorage.getItem('driver_in_service')
|
||||
if (savedService === 'true' && isVerified.value) {
|
||||
// startService() // Consider if auto-start is safe
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopService()
|
||||
})
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const user = await authService.getCurrentUser()
|
||||
isVerified.value = user.is_verified
|
||||
driverProfile.value = user.driver_profile
|
||||
|
||||
// Update local storage just in case
|
||||
localStorage.setItem('user_verified', user.is_verified.toString())
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch driver status', e)
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.clear()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
async function toggleService() {
|
||||
if (isInService.value) {
|
||||
stopService()
|
||||
} else {
|
||||
await startService()
|
||||
}
|
||||
}
|
||||
|
||||
async function startService() {
|
||||
if (!navigator.geolocation) {
|
||||
alert('Tu navegador no soporta geolocalización')
|
||||
return
|
||||
}
|
||||
|
||||
isLocating.value = true
|
||||
isInService.value = true
|
||||
localStorage.setItem('driver_in_service', 'true')
|
||||
|
||||
watchId.value = navigator.geolocation.watchPosition(
|
||||
async (position) => {
|
||||
isLocating.value = false
|
||||
const now = Date.now()
|
||||
|
||||
// Throttling updates to save battery/bandwidth
|
||||
if (now - lastUpdate.value >= minUpdateInterval) {
|
||||
try {
|
||||
await telemetryService.sendTelemetry({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
speed: position.coords.speed || undefined,
|
||||
heading: position.coords.heading || undefined,
|
||||
status: 'active'
|
||||
})
|
||||
lastUpdate.value = now
|
||||
} catch (e) {
|
||||
console.error('Failed to send telemetry', e)
|
||||
}
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('Geolocation error', error)
|
||||
isLocating.value = false
|
||||
if (error.code === error.PERMISSION_DENIED) {
|
||||
alert('Debes permitir el acceso a la ubicación para usar esta función')
|
||||
stopService()
|
||||
}
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
maximumAge: 30000,
|
||||
timeout: 27000
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function stopService() {
|
||||
if (watchId.value !== null) {
|
||||
navigator.geolocation.clearWatch(watchId.value)
|
||||
watchId.value = null
|
||||
}
|
||||
|
||||
isInService.value = false
|
||||
isLocating.value = false
|
||||
localStorage.setItem('driver_in_service', 'false')
|
||||
|
||||
// Optionally notify backend that we are offline
|
||||
telemetryService.sendTelemetry({
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
status: 'offline'
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function editProfile() {
|
||||
alert('Función de edición de perfil próximamente')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.driver-dashboard {
|
||||
padding: 24px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.user-welcome h1 { margin: 0; font-size: 28px; }
|
||||
.user-welcome p { color: #666; margin: 4px 0 0 0; }
|
||||
|
||||
.logout-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.status-banner.pending {
|
||||
background-color: #fff8e1;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeeba;
|
||||
}
|
||||
|
||||
.status-banner.verified {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border: 1px solid #c8e6c9;
|
||||
}
|
||||
|
||||
.banner-text h3 { margin: 0; font-size: 18px; }
|
||||
.banner-text p { margin: 4px 0 0 0; font-size: 14px; opacity: 0.9; }
|
||||
|
||||
.status-banner .material-icons { font-size: 48px; }
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
background: var(--card-bg);
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dashboard-card.active-service {
|
||||
border-color: var(--active-color);
|
||||
box-shadow: 0 0 15px rgba(25, 118, 210, 0.2);
|
||||
}
|
||||
|
||||
.dashboard-card h3 { margin: 0 0 20px 0; font-size: 1.25rem; color: var(--text-primary); }
|
||||
|
||||
.card-header-flex {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card-header-flex h3 { margin: 0; }
|
||||
|
||||
.status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #ccc;
|
||||
}
|
||||
.status-indicator.online {
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 10px #4caf50;
|
||||
animation: blink 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-row label { color: var(--text-secondary); }
|
||||
.info-row span { font-weight: 600; color: var(--text-primary); }
|
||||
|
||||
.secondary-btn {
|
||||
margin-top: auto;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.secondary-btn:hover:not(:disabled) { background: var(--hover-bg); }
|
||||
.secondary-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.service-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.service-hint {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
.service-hint.active {
|
||||
color: var(--active-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.service-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.service-btn.start {
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
.service-btn.stop {
|
||||
background: #ef5350;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.locating-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin { 100% { transform: rotate(360deg); } }
|
||||
|
||||
.unavailable { opacity: 0.7; }
|
||||
|
||||
.coming-soon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.coming-soon .material-icons { font-size: 48px; margin-bottom: 8px; }
|
||||
</style>
|
||||
596
frontend/src/views/FavoritesView.vue
Normal file
596
frontend/src/views/FavoritesView.vue
Normal file
@ -0,0 +1,596 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFavoritesStore } from '@/stores/favorites'
|
||||
import { API_URL } from '@/services/apiClient'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const favoritesStore = useFavoritesStore()
|
||||
const selectedFilter = ref('all')
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await favoritesStore.loadFavorites()
|
||||
})
|
||||
|
||||
function getImageUrl(path?: string) {
|
||||
if (!path) return `https://ui-avatars.com/api/?name=Favorite&background=fee715&color=101820`
|
||||
if (path.startsWith('http')) return path
|
||||
return `${API_URL}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
}
|
||||
|
||||
async function removeFavorite(event: Event, itemType: string, itemId: string) {
|
||||
event.stopPropagation()
|
||||
if (confirm(t('favorites.removeConfirm'))) {
|
||||
await favoritesStore.removeFavorite(itemType, itemId)
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToItem = (item: any) => {
|
||||
if (item.item_type === 'route') {
|
||||
router.push('/schedules')
|
||||
} else if (item.item_type === 'taxi') {
|
||||
router.push('/taxi')
|
||||
} else if (item.item_type === 'business') {
|
||||
router.push('/business/' + item.item_id)
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="favorites-view">
|
||||
<!-- Hero Header -->
|
||||
<header class="favorites-header">
|
||||
<div class="header-overlay"></div>
|
||||
<div class="header-content">
|
||||
<div class="header-icon">
|
||||
<span class="material-icons">stars</span>
|
||||
</div>
|
||||
<h1>{{ t('favorites.title') }}</h1>
|
||||
<p class="subtitle">{{ t('favorites.subtitle') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Category Filter (igual a Descubrir) -->
|
||||
<div class="filters-container-wrapper">
|
||||
<div class="filters-card">
|
||||
<div class="filter-group">
|
||||
<label>
|
||||
<span class="material-icons">filter_list</span>
|
||||
Filtrar favoritos
|
||||
</label>
|
||||
<div class="select-wrapper">
|
||||
<select v-model="selectedFilter" class="filter-select">
|
||||
<option value="all">Todas las categorías</option>
|
||||
<option v-if="favoritesStore.routes.length > 0" value="routes">Rutas ({{ favoritesStore.routes.length }})</option>
|
||||
<option v-if="favoritesStore.taxis.length > 0" value="taxis">Taxis ({{ favoritesStore.taxis.length }})</option>
|
||||
<option v-if="favoritesStore.businesses.length > 0" value="businesses">Negocios ({{ favoritesStore.businesses.length }})</option>
|
||||
<option v-if="favoritesStore.coupons.length > 0" value="coupons">Eventos ({{ favoritesStore.coupons.length }})</option>
|
||||
</select>
|
||||
<span class="material-icons dropdown-icon">expand_more</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="favoritesStore.isLoading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>{{ t('common.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="dashboard-area">
|
||||
<!-- Global Empty State -->
|
||||
<div v-if="favoritesStore.favorites.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<span class="material-icons">favorite_border</span>
|
||||
</div>
|
||||
<h3>{{ t('favorites.empty.subtitle') }}</h3>
|
||||
</div>
|
||||
|
||||
<div v-else class="sections-list">
|
||||
<!-- ROUTES SECTION -->
|
||||
<section v-if="(selectedFilter === 'all' || selectedFilter === 'routes') && favoritesStore.routes.length > 0" class="fav-section">
|
||||
<div class="section-header">
|
||||
<span class="material-icons">directions_bus</span>
|
||||
<h2>{{ t('favorites.tabs.routes') }}</h2>
|
||||
</div>
|
||||
<div class="compact-list">
|
||||
<div v-for="item in favoritesStore.routes" :key="item.id" class="list-item" @click="navigateToItem(item)">
|
||||
<div class="item-visual bg-yellow">
|
||||
<span class="material-icons">directions_bus</span>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<h3>{{ item.item_name }}</h3>
|
||||
<p>{{ t('favorites.viewSchedules') }}</p>
|
||||
</div>
|
||||
<button class="item-remove" @click.stop="removeFavorite($event, item.item_type, item.item_id)">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TAXIS SECTION -->
|
||||
<section v-if="(selectedFilter === 'all' || selectedFilter === 'taxis') && favoritesStore.taxis.length > 0" class="fav-section">
|
||||
<div class="section-header">
|
||||
<span class="material-icons">local_taxi</span>
|
||||
<h2>{{ t('favorites.tabs.taxis') }}</h2>
|
||||
</div>
|
||||
<div class="compact-list">
|
||||
<div v-for="item in favoritesStore.taxis" :key="item.id" class="list-item" @click="navigateToItem(item)">
|
||||
<div class="item-visual thumb">
|
||||
<img :src="getImageUrl(item.item_image)" :alt="item.item_name">
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<h3>{{ item.item_name }}</h3>
|
||||
<p>{{ t('favorites.contact') }}</p>
|
||||
</div>
|
||||
<button class="item-remove" @click.stop="removeFavorite($event, item.item_type, item.item_id)">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- BUSINESSES SECTION -->
|
||||
<section v-if="(selectedFilter === 'all' || selectedFilter === 'businesses') && favoritesStore.businesses.length > 0" class="fav-section">
|
||||
<div class="section-header">
|
||||
<span class="material-icons">store</span>
|
||||
<h2>{{ t('favorites.tabs.businesses') }}</h2>
|
||||
</div>
|
||||
<div class="horizontal-scroll">
|
||||
<div v-for="item in favoritesStore.businesses" :key="item.id" class="fav-card-mini" @click="navigateToItem(item)">
|
||||
<div class="mini-card-img">
|
||||
<img :src="getImageUrl(item.item_image)" :alt="item.item_name">
|
||||
<button class="mini-remove" @click.stop="removeFavorite($event, item.item_type, item.item_id)">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mini-card-body">
|
||||
<h3>{{ item.item_name }}</h3>
|
||||
<p>{{ t('favorites.viewDetails') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- EVENTS SECTION -->
|
||||
<section v-if="(selectedFilter === 'all' || selectedFilter === 'coupons') && favoritesStore.coupons.length > 0" class="fav-section">
|
||||
<div class="section-header">
|
||||
<span class="material-icons">event</span>
|
||||
<h2>{{ t('favorites.tabs.coupons') }}</h2>
|
||||
</div>
|
||||
<div class="compact-list">
|
||||
<div v-for="item in favoritesStore.coupons" :key="item.id" class="list-item">
|
||||
<div class="item-visual thumb">
|
||||
<img :src="getImageUrl(item.item_image)" :alt="item.item_name">
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<h3>{{ item.item_name }}</h3>
|
||||
<p>{{ t('favorites.saved') }}</p>
|
||||
</div>
|
||||
<button class="item-remove" @click.stop="removeFavorite($event, item.item_type, item.item_id)">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.favorites-view {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
padding-bottom: 80px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Hero Header */
|
||||
.favorites-header {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
|
||||
color: #101820;
|
||||
padding: 40px 20px 60px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-overlay {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 50%, rgba(255,255,255,0.2) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 80%, rgba(255,255,255,0.2) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 20px;
|
||||
background: rgba(16, 24, 32, 0.1);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.header-icon .material-icons {
|
||||
font-size: 40px;
|
||||
color: #101820;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 900;
|
||||
margin-bottom: 12px;
|
||||
color: #101820;
|
||||
}
|
||||
|
||||
/* Filter Card Area */
|
||||
.filters-container-wrapper {
|
||||
max-width: 1200px;
|
||||
margin: 30px auto 30px;
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.filters-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--active-color);
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-group label .material-icons {
|
||||
font-size: 20px;
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
.select-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 100%;
|
||||
padding: 14px 44px 14px 16px;
|
||||
border-radius: 14px;
|
||||
border: 2px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.filter-select option {
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--active-color);
|
||||
box-shadow: 0 0 0 4px rgba(254, 231, 21, 0.1);
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Dashboard Sections */
|
||||
.sections-list {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.fav-section {
|
||||
scroll-margin-top: 100px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-header .material-icons {
|
||||
color: var(--active-color);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 900;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Compact List Style */
|
||||
.compact-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
transform: translateX(10px);
|
||||
border-color: var(--active-color);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.item-visual {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-visual.bg-yellow {
|
||||
background: var(--active-color);
|
||||
color: #101820;
|
||||
}
|
||||
|
||||
.item-visual.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.item-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-details h3 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.item-details p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.item-remove {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.item-remove:hover {
|
||||
background: #ff4757;
|
||||
color: white;
|
||||
border-color: #ff4757;
|
||||
}
|
||||
|
||||
/* Horizontal Scroll for Businesses */
|
||||
.horizontal-scroll {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow-x: auto;
|
||||
padding: 4px 4px 20px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.horizontal-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fav-card-mini {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: var(--card-bg);
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fav-card-mini:hover {
|
||||
transform: translateY(-8px);
|
||||
border-color: var(--active-color);
|
||||
}
|
||||
|
||||
.mini-card-img {
|
||||
height: 140px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mini-card-img img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mini-remove {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
color: white;
|
||||
border: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mini-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mini-card-body h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mini-card-body p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--active-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* States */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
background: var(--header-bg);
|
||||
border-radius: 30px;
|
||||
border: 2px dashed var(--border-color);
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin: 0 auto 24px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--active-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 900;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cta-link {
|
||||
display: inline-flex;
|
||||
padding: 16px 32px;
|
||||
background: var(--active-color);
|
||||
color: #101820;
|
||||
text-decoration: none;
|
||||
border-radius: 16px;
|
||||
font-weight: 900;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.favorites-header {
|
||||
padding: 40px 16px 60px;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 2.1rem;
|
||||
}
|
||||
|
||||
.sections-list {
|
||||
padding: 0 16px;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.item-visual {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item-details h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.fav-card-mini {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
2131
frontend/src/views/MapView.vue
Normal file
2131
frontend/src/views/MapView.vue
Normal file
File diff suppressed because it is too large
Load Diff
720
frontend/src/views/ProfileView.vue
Normal file
720
frontend/src/views/ProfileView.vue
Normal file
@ -0,0 +1,720 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useCouponStore } from '@/stores/coupon'
|
||||
import { authService } from '@/services/authService'
|
||||
|
||||
const router = useRouter()
|
||||
const couponStore = useCouponStore()
|
||||
|
||||
const userName = ref(localStorage.getItem('user_name') || 'Usuario')
|
||||
const userEmail = ref(localStorage.getItem('user_email') || '')
|
||||
const userRole = ref(localStorage.getItem('user_role') || 'PASSENGER')
|
||||
const userPhoto = ref(localStorage.getItem('profile_photo_url') || '')
|
||||
|
||||
const showQRModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const isUpdating = ref(false)
|
||||
const selectedCode = ref('')
|
||||
const selectedTitle = ref('')
|
||||
|
||||
// Edit Form
|
||||
const editForm = ref({
|
||||
full_name: userName.value,
|
||||
password: '',
|
||||
profile_photo: null as File | null
|
||||
})
|
||||
const photoPreview = ref(userPhoto.value)
|
||||
|
||||
onMounted(async () => {
|
||||
await couponStore.loadMyCoupons()
|
||||
// Refresh user data from server to be sure
|
||||
try {
|
||||
const freshUser = await authService.getCurrentUser()
|
||||
userName.value = freshUser.full_name
|
||||
userEmail.value = freshUser.email
|
||||
userRole.value = freshUser.role
|
||||
userPhoto.value = freshUser.profile_photo_url || ''
|
||||
|
||||
localStorage.setItem('user_name', freshUser.full_name)
|
||||
localStorage.setItem('profile_photo_url', freshUser.profile_photo_url || '')
|
||||
photoPreview.value = userPhoto.value
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh user data', e)
|
||||
}
|
||||
})
|
||||
|
||||
function handleLogout() {
|
||||
authService.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function showQR(code: string, title: string) {
|
||||
selectedCode.value = code
|
||||
selectedTitle.value = title
|
||||
showQRModal.value = true
|
||||
}
|
||||
|
||||
function handlePhotoChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
if (target.files && target.files[0]) {
|
||||
const file = target.files[0]
|
||||
editForm.value.profile_photo = file
|
||||
photoPreview.value = URL.createObjectURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateProfile() {
|
||||
isUpdating.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('full_name', editForm.value.full_name)
|
||||
if (editForm.value.password) {
|
||||
formData.append('password', editForm.value.password)
|
||||
}
|
||||
if (editForm.value.profile_photo) {
|
||||
formData.append('profile_photo', editForm.value.profile_photo)
|
||||
}
|
||||
|
||||
const updatedUser = await authService.updateMe(formData)
|
||||
|
||||
// Update local state
|
||||
userName.value = updatedUser.full_name
|
||||
userPhoto.value = updatedUser.profile_photo_url || ''
|
||||
|
||||
// Update localStorage
|
||||
localStorage.setItem('user_name', updatedUser.full_name)
|
||||
localStorage.setItem('profile_photo_url', updatedUser.profile_photo_url || '')
|
||||
|
||||
showEditModal.value = false
|
||||
editForm.value.password = ''
|
||||
alert('Perfil actualizado correctamente')
|
||||
} catch (e: any) {
|
||||
alert('Error al actualizar: ' + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'claimed': return 'Pendiente'
|
||||
case 'redeemed': return 'Canjeado'
|
||||
case 'expired': return 'Vencido'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
function getFullUrl(path: string) {
|
||||
if (!path) return ''
|
||||
if (path.startsWith('http')) return path
|
||||
return `${API_URL}${path}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profile-view">
|
||||
<header class="profile-header">
|
||||
<div class="profile-info-card">
|
||||
<div class="avatar-container">
|
||||
<div v-if="userPhoto" class="avatar-img" :style="{ backgroundImage: `url(${getFullUrl(userPhoto)})` }"></div>
|
||||
<div v-else class="avatar-placeholder">
|
||||
<span class="material-icons">person</span>
|
||||
</div>
|
||||
<button class="edit-badge" @click="showEditModal = true">
|
||||
<span class="material-icons">edit</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<h1>{{ userName }}</h1>
|
||||
<p>{{ userEmail }}</p>
|
||||
<div class="badge-row">
|
||||
<span class="role-badge">{{ userRole }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<button class="logout-icon-btn" @click="handleLogout" title="Cerrar Sesión">
|
||||
<span class="material-icons">logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="my-coupons-section">
|
||||
<div class="section-header">
|
||||
<h2>Mis Cupones</h2>
|
||||
<span class="count">{{ couponStore.myCoupons.length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="couponStore.myCoupons.length === 0" class="empty-coupons">
|
||||
<div class="empty-icon-circle">
|
||||
<span class="material-icons">confirmation_number</span>
|
||||
</div>
|
||||
<h3>No tienes cupones</h3>
|
||||
<p>Explora los beneficios que tenemos para ti por usar SIBU.</p>
|
||||
<button @click="router.push('/coupons')" class="btn-primary">Ver Ofertas</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="coupons-list">
|
||||
<div
|
||||
v-for="userCoupon in couponStore.myCoupons"
|
||||
:key="userCoupon.id"
|
||||
:class="['user-coupon-card', userCoupon.status]"
|
||||
>
|
||||
<div class="coupon-main">
|
||||
<div class="coupon-details">
|
||||
<h3>{{ userCoupon.coupon?.title || 'Cupón' }}</h3>
|
||||
<p class="biz-name">{{ userCoupon.coupon?.business_name || 'Comercio' }}</p>
|
||||
<div class="code-row">
|
||||
<span class="code">{{ userCoupon.redemption_code }}</span>
|
||||
<span :class="['status-tag', userCoupon.status]">{{ getStatusLabel(userCoupon.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="userCoupon.status === 'claimed'"
|
||||
class="btn-use"
|
||||
@click="showQR(userCoupon.redemption_code, userCoupon.coupon?.title || '')"
|
||||
>
|
||||
<span class="material-icons">qr_code_2</span>
|
||||
Ver Código
|
||||
</button>
|
||||
</div>
|
||||
<div class="coupon-footer">
|
||||
<span v-if="userCoupon.status === 'redeemed'">Usado el: {{ new Date(userCoupon.redeemed_at).toLocaleDateString() }}</span>
|
||||
<span v-else>Reclamado el: {{ new Date(userCoupon.claimed_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Edit Profile Modal -->
|
||||
<div v-if="showEditModal" class="modal-overlay" @click.self="showEditModal = false">
|
||||
<div class="edit-modal">
|
||||
<div class="modal-header">
|
||||
<h2>Editar Perfil</h2>
|
||||
<button class="close-btn" @click="showEditModal = false">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleUpdateProfile" class="edit-form">
|
||||
<div class="photo-upload-section">
|
||||
<div class="photo-preview-container">
|
||||
<div v-if="photoPreview" class="preview-img" :style="{ backgroundImage: `url(${getFullUrl(photoPreview)})` }"></div>
|
||||
<div v-else class="preview-placeholder">
|
||||
<span class="material-icons">person</span>
|
||||
</div>
|
||||
<label for="photo-input" class="photo-label">
|
||||
<span class="material-icons">photo_camera</span>
|
||||
</label>
|
||||
</div>
|
||||
<input id="photo-input" type="file" @change="handlePhotoChange" accept="image/*" hidden>
|
||||
<p class="upload-hint">Foto opcional</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Nombre Completo</label>
|
||||
<input v-model="editForm.full_name" type="text" placeholder="Tu nombre" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Nueva Contraseña (Opcional)</label>
|
||||
<input v-model="editForm.password" type="password" placeholder="Mínimo 6 caracteres">
|
||||
<p class="field-hint">Déjalo en blanco si no quieres cambiarla.</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-cancel" @click="showEditModal = false">Cancelar</button>
|
||||
<button type="submit" class="btn-save" :disabled="isUpdating">
|
||||
<span v-if="isUpdating" class="material-icons spin">refresh</span>
|
||||
{{ isUpdating ? 'Guardando...' : 'Guardar Cambios' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Modal -->
|
||||
<div v-if="showQRModal" class="modal-overlay" @click.self="showQRModal = false">
|
||||
<div class="qr-modal">
|
||||
<button class="close-modal" @click="showQRModal = false">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
<div class="qr-header">
|
||||
<span class="material-icons">verified</span>
|
||||
<h3>Cupón de Descuento</h3>
|
||||
</div>
|
||||
<p class="promo-title">{{ selectedTitle }}</p>
|
||||
|
||||
<div class="qr-content">
|
||||
<div class="qr-placeholder">
|
||||
<span class="material-icons">qr_code_2</span>
|
||||
</div>
|
||||
<div class="redemption-box">
|
||||
<p>CÓDIGO DE REDENCIÓN</p>
|
||||
<code class="big-code">{{ selectedCode }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="qr-instructions">Muestra este código al encargado del establecimiento para validar tu promoción.</p>
|
||||
|
||||
<button class="btn-done" @click="showQRModal = false">Entendido</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-view {
|
||||
padding: 1.5rem;
|
||||
background: var(--bg-primary);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.profile-info-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 28px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
box-shadow: 0 10px 30px var(--shadow);
|
||||
border: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.avatar-img, .avatar-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 24px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border: 3px solid var(--header-bg);
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-placeholder .material-icons {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.edit-badge {
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
right: -5px;
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.edit-badge .material-icons { font-size: 1rem; }
|
||||
|
||||
.info h1 { font-size: 1.4rem; margin-bottom: 2px; }
|
||||
.info p { color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 8px; }
|
||||
|
||||
.role-badge {
|
||||
background: var(--active-bg);
|
||||
color: var(--active-color);
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.logout-icon-btn {
|
||||
background: #ffebf0;
|
||||
color: #d32f2f;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.logout-icon-btn:hover { background: #d32f2f; color: white; }
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-header h2 { font-size: 1.25rem; }
|
||||
|
||||
.section-header .count {
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-weight: 800;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.empty-coupons {
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
background: var(--card-bg);
|
||||
border-radius: 24px;
|
||||
border: 2px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.empty-icon-circle {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-icon-circle .material-icons { font-size: 2.5rem; }
|
||||
|
||||
.btn-primary {
|
||||
margin-top: 1.5rem;
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.coupons-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.user-coupon-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 15px var(--shadow);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.user-coupon-card:hover { transform: scale(1.02); }
|
||||
|
||||
.coupon-main {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.coupon-details h3 { font-size: 1.1rem; margin-bottom: 4px; }
|
||||
.biz-name { font-size: 0.9rem; color: var(--active-color); font-weight: 700; }
|
||||
|
||||
.code-row {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
background: var(--bg-secondary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-weight: 800;
|
||||
font-size: 1rem;
|
||||
border: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-tag.claimed { background: #e3f2fd; color: #1976d2; }
|
||||
.status-tag.redeemed { background: #e8f5e9; color: #2e7d32; opacity: 0.7; }
|
||||
|
||||
.btn-use {
|
||||
background: var(--header-bg);
|
||||
color: var(--header-text);
|
||||
border: none;
|
||||
padding: 12px 18px;
|
||||
border-radius: 14px;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.coupon-footer {
|
||||
padding: 10px 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Modal Base */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Edit Modal */
|
||||
.edit-modal {
|
||||
background: var(--card-bg);
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
border-radius: 32px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.close-btn, .close-modal {
|
||||
background: var(--bg-secondary);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.photo-upload-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.photo-preview-container {
|
||||
position: relative;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.preview-img, .preview-placeholder {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 30px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border: 4px solid var(--header-bg);
|
||||
}
|
||||
|
||||
.preview-placeholder {
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-placeholder .material-icons { font-size: 4rem; color: var(--text-secondary); }
|
||||
|
||||
.photo-label {
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
right: -5px;
|
||||
background: var(--active-color);
|
||||
color: white;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.field-hint, .upload-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
flex: 2;
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
flex: 1;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* QR Modal Enhanced */
|
||||
.qr-modal {
|
||||
background: var(--card-bg);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border-radius: 40px;
|
||||
padding: 2.5rem;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.qr-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.qr-header .material-icons { color: #4caf50; font-size: 3rem; }
|
||||
.qr-header h3 { font-size: 1.5rem; }
|
||||
|
||||
.promo-title {
|
||||
color: var(--active-color);
|
||||
font-weight: 800;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.qr-content {
|
||||
background: #f8f9fa;
|
||||
padding: 2rem;
|
||||
border-radius: 30px;
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
.qr-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: white;
|
||||
margin: 0 auto 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.qr-placeholder .material-icons { font-size: 6rem; color: #222; }
|
||||
|
||||
.redemption-box p { font-size: 0.7rem; font-weight: 800; color: #888; letter-spacing: 1px; margin-bottom: 4px; }
|
||||
|
||||
.big-code {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 900;
|
||||
color: #111;
|
||||
letter-spacing: 3px;
|
||||
}
|
||||
|
||||
.qr-instructions {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn-done {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { 100% { transform: rotate(360deg); } }
|
||||
</style>
|
||||
1188
frontend/src/views/PromoterDashboard.vue
Normal file
1188
frontend/src/views/PromoterDashboard.vue
Normal file
File diff suppressed because it is too large
Load Diff
288
frontend/src/views/RoutesView.vue
Normal file
288
frontend/src/views/RoutesView.vue
Normal file
@ -0,0 +1,288 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouteStore } from '@/stores/route'
|
||||
import { analyticsService } from '@/services/analyticsService'
|
||||
import FavoriteButton from '@/components/FavoriteButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const routeStore = useRouteStore()
|
||||
|
||||
const originSearch = ref('')
|
||||
const destinationSearch = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
analyticsService.logEvent({ event_name: 'screen_view', screen_name: 'Routes' })
|
||||
await routeStore.loadRoutes()
|
||||
})
|
||||
|
||||
const handleSearch = async () => {
|
||||
await routeStore.loadRoutes({
|
||||
originCity: originSearch.value,
|
||||
destinationCity: destinationSearch.value
|
||||
})
|
||||
}
|
||||
|
||||
const goToSchedules = (route: any) => {
|
||||
analyticsService.logEvent({
|
||||
event_name: 'route_selected',
|
||||
item_id: route.name,
|
||||
properties: { route_id: route.id }
|
||||
})
|
||||
routeStore.selectRoute(route.id, route.name)
|
||||
router.push('/schedules')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="routes-view">
|
||||
<div class="header">
|
||||
<h1>Búsqueda de Rutas</h1>
|
||||
<p>Encuentra tu próximo viaje fácilmente</p>
|
||||
</div>
|
||||
|
||||
<div class="search-box">
|
||||
<div class="input-group">
|
||||
<span class="material-icons">location_on</span>
|
||||
<input
|
||||
v-model="originSearch"
|
||||
placeholder="Origen (Ciudad)"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
</div>
|
||||
<div class="divider">
|
||||
<span class="material-icons">swap_vert</span>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<span class="material-icons">flag</span>
|
||||
<input
|
||||
v-model="destinationSearch"
|
||||
placeholder="Destino (Ciudad)"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
</div>
|
||||
<button @click="handleSearch" class="search-btn">
|
||||
<span class="material-icons">search</span>
|
||||
Buscar Rutas
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="routeStore.isLoadingRoutes" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Buscando mejores rutas...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="routeStore.allRoutes.length === 0" class="no-results">
|
||||
<span class="material-icons">sentiment_dissatisfied</span>
|
||||
<p>No encontramos rutas que coincidan con tu búsqueda.</p>
|
||||
<button @click="originSearch = ''; destinationSearch = ''; handleSearch()" class="reset-btn">Ver todas las rutas</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="routes-list">
|
||||
<div
|
||||
v-for="route in routeStore.allRoutes"
|
||||
:key="route.id"
|
||||
class="route-card"
|
||||
@click="goToSchedules(route)"
|
||||
>
|
||||
<div class="route-header">
|
||||
<div class="route-name">
|
||||
<span class="dot" :style="{ backgroundColor: route.color || '#fee715' }"></span>
|
||||
<h3>{{ route.name }}</h3>
|
||||
</div>
|
||||
<div class="route-actions">
|
||||
<FavoriteButton
|
||||
item-type="route"
|
||||
:item-id="route.id"
|
||||
:item-name="route.name"
|
||||
/>
|
||||
<span class="material-icons chevron">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="route-details">
|
||||
<div class="city-flow">
|
||||
<span>{{ route.origin_city }}</span>
|
||||
<span class="material-icons">arrow_forward</span>
|
||||
<span>{{ route.destination_city }}</span>
|
||||
</div>
|
||||
<p v-if="route.distance_km" class="meta">
|
||||
{{ route.distance_km }} km • {{ route.estimated_duration_minutes }} min aprox.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.routes-view {
|
||||
padding: 24px;
|
||||
background: var(--bg-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
background: var(--card-bg);
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 10px 30px var(--shadow);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 12px 20px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 8px 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
background: var(--header-bg);
|
||||
color: var(--header-text);
|
||||
border: none;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.search-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.routes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.route-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 15px var(--shadow);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.route-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.route-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.route-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.route-name h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.city-flow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.route-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: #ced4da;
|
||||
}
|
||||
|
||||
.loading, .no-results {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #fee715;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
margin-top: 16px;
|
||||
background: transparent;
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
472
frontend/src/views/SchedulesView.vue
Normal file
472
frontend/src/views/SchedulesView.vue
Normal file
@ -0,0 +1,472 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useScheduleStore } from '@/stores/schedule'
|
||||
import { useRouteStore } from '@/stores/route'
|
||||
import { formatTo12Hour } from '@/utils/timeFormatter'
|
||||
import { analyticsService } from '@/services/analyticsService'
|
||||
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const scheduleStore = useScheduleStore()
|
||||
const routeStore = useRouteStore()
|
||||
|
||||
const showRouteDropdown = ref(false)
|
||||
const routeCardRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (routeCardRef.value && !routeCardRef.value.contains(event.target as Node)) {
|
||||
showRouteDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
analyticsService.logEvent({ event_name: 'screen_view', screen_name: 'Schedules' })
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
await routeStore.loadRoutes()
|
||||
|
||||
// Point 1: Smart synchronization from MapView
|
||||
const queryRouteId = route.query.routeId as string
|
||||
if (queryRouteId) {
|
||||
const foundRoute = routeStore.allRoutes.find(r => r.id === queryRouteId)
|
||||
if (foundRoute) {
|
||||
// FIX: Use sync function to avoid redirect loop back to map
|
||||
syncRouteSelection(foundRoute.id, foundRoute.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const unwatchQuery = watch(
|
||||
() => route.query.routeId,
|
||||
(newRouteId) => {
|
||||
if (newRouteId) {
|
||||
const foundRoute = routeStore.allRoutes.find(r => r.id === newRouteId as string)
|
||||
if (foundRoute) {
|
||||
syncRouteSelection(foundRoute.id, foundRoute.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
unwatchQuery()
|
||||
})
|
||||
|
||||
function syncRouteSelection(routeId: string, routeName: string) {
|
||||
routeStore.selectRoute(routeId, routeName)
|
||||
scheduleStore.loadRouteSchedules(routeId)
|
||||
showRouteDropdown.value = false
|
||||
}
|
||||
|
||||
function selectRouteAndClose(routeId: string, routeName: string) {
|
||||
analyticsService.logEvent({
|
||||
event_name: 'schedule_viewed',
|
||||
item_id: routeName,
|
||||
properties: { route_id: routeId }
|
||||
})
|
||||
|
||||
routeStore.selectRoute(routeId, routeName)
|
||||
scheduleStore.loadRouteSchedules(routeId)
|
||||
showRouteDropdown.value = false
|
||||
}
|
||||
|
||||
function goToMap() {
|
||||
if (routeStore.selectedRouteId) {
|
||||
router.push({
|
||||
path: '/map',
|
||||
query: { routeId: routeStore.selectedRouteId }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function clearRouteAndClose() {
|
||||
routeStore.clearSelection()
|
||||
scheduleStore.schedules = []
|
||||
showRouteDropdown.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="schedules-view">
|
||||
<h1>{{ t('schedules.title') }}</h1>
|
||||
|
||||
<div v-if="routeStore.isLoadingRoutes">
|
||||
<p>{{ t('schedules.loadingRoutes') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="routeStore.allRoutes.length === 0">
|
||||
<p>{{ t('schedules.noRoutesAvailable') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Route selector card (same style as MapView) -->
|
||||
<div v-if="routeStore.allRoutes.length > 0" class="route-card" ref="routeCardRef">
|
||||
<div class="route-card-content" @click.stop="showRouteDropdown = !showRouteDropdown">
|
||||
<span class="material-icons route-icon">route</span>
|
||||
<div class="route-info">
|
||||
<div v-if="routeStore.selectedRouteId && routeStore.selectedRouteName" class="route-name">
|
||||
{{ t('schedules.route') }}: {{ routeStore.selectedRouteName }}
|
||||
</div>
|
||||
<div v-else class="route-name">{{ t('schedules.selectRoute') }}</div>
|
||||
<div v-if="routeStore.selectedRouteId && scheduleStore.schedules.length > 0" class="route-stops">
|
||||
{{ scheduleStore.schedules.length }} {{ t('schedules.schedules') }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="material-icons arrow-icon" :class="{ 'rotated': showRouteDropdown }">
|
||||
keyboard_arrow_down
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<div v-if="showRouteDropdown" class="route-dropdown" @click.stop>
|
||||
<div
|
||||
v-for="route in routeStore.allRoutes"
|
||||
:key="route.id"
|
||||
class="route-option"
|
||||
:class="{ 'selected': route.id === routeStore.selectedRouteId }"
|
||||
@click="selectRouteAndClose(route.id, route.name)"
|
||||
>
|
||||
{{ route.name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="routeStore.selectedRouteId"
|
||||
class="route-option clear-option"
|
||||
@click="clearRouteAndClose"
|
||||
>
|
||||
{{ t('common.clearSelection') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="routeStore.selectedRouteId" class="schedules-content">
|
||||
<div class="schedules-header">
|
||||
<h2 v-if="routeStore.selectedRouteName">{{ routeStore.selectedRouteName }}</h2>
|
||||
<button class="view-route-btn" @click="goToMap">
|
||||
<span class="material-icons">map</span>
|
||||
Ver ruta
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="scheduleStore.isLoading" class="schedules-loading">
|
||||
<span class="material-icons spin">refresh</span>
|
||||
<p>Cargando horarios...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="scheduleStore.error" class="schedules-error">
|
||||
<p>{{ scheduleStore.error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="scheduleStore.schedules.length > 0">
|
||||
<ul class="schedule-list">
|
||||
<li v-for="schedule in scheduleStore.schedules" :key="schedule.id" class="schedule-item">
|
||||
<div class="schedule-item-info">
|
||||
<span class="material-icons">schedule</span>
|
||||
<span class="departure-time">{{ formatTo12Hour(schedule.departure_time) }}</span>
|
||||
</div>
|
||||
<span class="schedule-type">{{ schedule.schedule_type }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-else class="schedules-empty">
|
||||
<span class="material-icons">event_busy</span>
|
||||
<p>No hay horarios disponibles para esta ruta.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.schedules-view {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
padding: 1.5rem 1rem 150px;
|
||||
color: var(--text-primary);
|
||||
overflow-x: hidden;
|
||||
background: var(--bg-primary) !important;
|
||||
}
|
||||
|
||||
|
||||
/* Asegurar que el contenido flote sobre el fondo */
|
||||
.schedules-view > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.schedules-view h1 {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 800;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.route-card {
|
||||
margin-bottom: 2rem;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.route-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.route-card-content:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 30px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.route-card-content:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.route-icon {
|
||||
color: var(--active-color);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.route-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.route-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.route-stops {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
color: var(--text-secondary);
|
||||
font-size: 24px;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.arrow-icon.rotated {
|
||||
transform: rotate(180deg);
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
.route-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 10px;
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.route-option {
|
||||
padding: 14px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.route-option:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.route-option:hover {
|
||||
background-color: var(--hover-bg);
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.route-option.selected {
|
||||
background-color: var(--active-bg);
|
||||
color: var(--active-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.route-option.clear-option {
|
||||
color: #ef4444; /* Rojo para limpiar */
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.schedules-content {
|
||||
margin-top: 1.5rem;
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
padding: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
max-width: 800px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.schedules-content h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--active-color);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.schedules-loading, .schedules-empty, .schedules-error {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
font-size: 3rem;
|
||||
color: var(--active-color);
|
||||
margin-bottom: 16px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.schedule-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.schedule-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.schedule-item:hover {
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
border-color: var(--active-color);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.schedule-item-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.schedule-item .material-icons {
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
.departure-time {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.schedule-type {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
padding: 5px 10px;
|
||||
background: var(--active-bg);
|
||||
color: var(--active-color);
|
||||
border-radius: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.schedules-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.schedules-header h2 {
|
||||
margin-bottom: 0 !important;
|
||||
text-align: left !important;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.view-route-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
background: #FEE715;
|
||||
color: #101820;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.view-route-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.view-route-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.schedules-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
.schedules-header h2 {
|
||||
text-align: center !important;
|
||||
}
|
||||
.view-route-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
241
frontend/src/views/SplashScreen.vue
Normal file
241
frontend/src/views/SplashScreen.vue
Normal file
@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="splash-screen">
|
||||
<div class="splash-content">
|
||||
<!-- Logo with animation -->
|
||||
<div class="logo-container" :class="{ 'logo-visible': logoVisible }">
|
||||
<div class="logo-box">
|
||||
<img src="/icon-192.png" alt="SIBU" class="logo-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div v-if="showLoading" class="loading-container" :class="{ 'loading-visible': loadingVisible }">
|
||||
<div class="spinner"></div>
|
||||
<p class="status-message">{{ statusMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Version info -->
|
||||
<div class="version-info">
|
||||
<p class="app-subtitle">Transporte Público Boquete</p>
|
||||
<p class="app-version">Versión 1.2.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouteStore } from '@/stores/route'
|
||||
import { useBusStopStore } from '@/stores/busStop'
|
||||
|
||||
const router = useRouter()
|
||||
const routeStore = useRouteStore()
|
||||
const busStopStore = useBusStopStore()
|
||||
|
||||
const logoVisible = ref(false)
|
||||
const showLoading = ref(false)
|
||||
const loadingVisible = ref(false)
|
||||
const statusMessage = ref('Iniciando SIBU...')
|
||||
|
||||
onMounted(async () => {
|
||||
// Start logo animation
|
||||
logoVisible.value = true
|
||||
|
||||
// Show loading indicator
|
||||
showLoading.value = true
|
||||
loadingVisible.value = true
|
||||
|
||||
// Perform initialization tasks with a safety timeout
|
||||
const initTimeout = setTimeout(() => {
|
||||
console.warn('Initialization taking too long, forcing navigation...')
|
||||
statusMessage.value = 'Iniciando de todas formas...'
|
||||
navigate()
|
||||
}, 5000)
|
||||
|
||||
try {
|
||||
await performInitializationTasks()
|
||||
clearTimeout(initTimeout)
|
||||
navigate()
|
||||
} catch (error) {
|
||||
console.error('Initialization failed', error)
|
||||
clearTimeout(initTimeout)
|
||||
navigate()
|
||||
}
|
||||
})
|
||||
|
||||
function navigate() {
|
||||
// Navigate based on role
|
||||
const role = localStorage.getItem('user_role')?.toUpperCase()
|
||||
if (role === 'ADMIN') {
|
||||
router.replace('/admin')
|
||||
} else if (role === 'DRIVER') {
|
||||
router.replace('/driver')
|
||||
} else if (role === 'PROMOTER') {
|
||||
router.replace('/promoter')
|
||||
} else {
|
||||
router.replace('/map')
|
||||
}
|
||||
}
|
||||
|
||||
async function performInitializationTasks() {
|
||||
// Task 1: Check connection and load routes
|
||||
statusMessage.value = 'Cargando datos de rutas...'
|
||||
try {
|
||||
await routeStore.loadRoutes()
|
||||
} catch (error) {
|
||||
console.error('Error loading routes:', error)
|
||||
}
|
||||
|
||||
// Task 2: Load bus stops
|
||||
statusMessage.value = 'Cargando paradas...'
|
||||
try {
|
||||
await busStopStore.loadBusStops()
|
||||
} catch (error) {
|
||||
console.error('Error loading bus stops:', error)
|
||||
}
|
||||
|
||||
// Task 3: Prepared
|
||||
statusMessage.value = 'Listo para usar'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.splash-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-color: #101820;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.splash-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
transition: opacity 0.6s ease-out, transform 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.logo-container.logo-visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.logo-box {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
background-color: #fee715;
|
||||
border-radius: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
margin-top: 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.8s ease-in;
|
||||
}
|
||||
|
||||
.loading-container.loading-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid rgba(254, 231, 21, 0.3);
|
||||
border-top-color: #fee715;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.status-message {
|
||||
margin-top: 16px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
|
||||
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
.version-info {
|
||||
position: absolute;
|
||||
bottom: 64px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-subtitle {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
|
||||
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
|
||||
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.logo-box {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 85px;
|
||||
height: 85px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.version-info {
|
||||
bottom: 48px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
482
frontend/src/views/StrategicAnalytics.vue
Normal file
482
frontend/src/views/StrategicAnalytics.vue
Normal file
@ -0,0 +1,482 @@
|
||||
<template>
|
||||
<div class="strategic-analytics">
|
||||
<div class="header-section">
|
||||
<div class="top-row">
|
||||
<button class="download-btn" @click="generateReport">
|
||||
<span class="material-icons">description</span>
|
||||
Descargar Informe
|
||||
</button>
|
||||
<div class="badge">INTELIGENCIA ESTRATÉGICA</div>
|
||||
</div>
|
||||
<h1>Centro de Operaciones</h1>
|
||||
<p class="subtitle">Análisis segmentado de rendimiento SIBU</p>
|
||||
</div>
|
||||
|
||||
<!-- TACTICAL TAB SELECTOR -->
|
||||
<div class="tabs-control">
|
||||
<button
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === 'overview' }"
|
||||
@click="activeTab = 'overview'"
|
||||
>
|
||||
<span class="material-icons">dashboard</span>
|
||||
Visión General
|
||||
</button>
|
||||
<button
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === 'transport' }"
|
||||
@click="activeTab = 'transport'"
|
||||
>
|
||||
<span class="material-icons">directions_bus</span>
|
||||
Logística de Transporte
|
||||
</button>
|
||||
<button
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === 'commerce' }"
|
||||
@click="activeTab = 'commerce'"
|
||||
>
|
||||
<span class="material-icons">storefront</span>
|
||||
Inteligencia Comercial
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<span class="material-icons spin">sync</span>
|
||||
<p>Sincronizando con la red...</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- SECTION 1: OVERVIEW -->
|
||||
<div v-if="activeTab === 'overview'" class="tab-content animate-fade">
|
||||
<div class="dashboard-layout">
|
||||
<div class="main-content">
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi-card user-active">
|
||||
<div class="kpi-icon"><span class="material-icons">person</span></div>
|
||||
<div class="kpi-data">
|
||||
<span class="kpi-value">{{ stats.users?.registered_active || 0 }}</span>
|
||||
<span class="kpi-label">Usuarios Registrados Activos</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon"><span class="material-icons">analytics</span></div>
|
||||
<div class="kpi-data">
|
||||
<span class="kpi-value">{{ totalInteractionCount }}</span>
|
||||
<span class="kpi-label">Interacciones Totales Hoy</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="analysis-section mini">
|
||||
<div class="section-header">
|
||||
<span class="material-icons">schedule</span>
|
||||
<h2>Mapa de Calor Horario</h2>
|
||||
</div>
|
||||
<div class="chart-container large">
|
||||
<Line :data="usageChartData" :options="usageChartOptions" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<aside class="side-info">
|
||||
<div class="info-box">
|
||||
<span class="material-icons">groups</span>
|
||||
<h4>Control de Tráfico</h4>
|
||||
<p>Esta sección muestra la salud general de la app. Si la línea de invitados supera por mucho a la de registrados, es momento de lanzar una campaña de fidelización.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 2: TRANSPORT -->
|
||||
<div v-if="activeTab === 'transport'" class="tab-content animate-fade">
|
||||
<div class="dashboard-layout">
|
||||
<div class="main-content">
|
||||
<!-- RUTAS -->
|
||||
<section class="analysis-section">
|
||||
<div class="section-header">
|
||||
<span class="material-icons">bar_chart</span>
|
||||
<h2>Rutas Turísticas más Consultadas</h2>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<Bar :data="routesChartData" :options="routesChartOptions" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CASETAS -->
|
||||
<section class="analysis-section">
|
||||
<div class="section-header">
|
||||
<span class="material-icons">location_on</span>
|
||||
<h2>Puntos de Interés: Casetas (Paradas)</h2>
|
||||
</div>
|
||||
<div class="data-table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID Caseta</th>
|
||||
<th>Peticiones</th>
|
||||
<th>Popularidad</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="stop in stats.top_stops" :key="stop.id">
|
||||
<td class="id-cell"># {{ stop.id }}</td>
|
||||
<td>{{ stop.count }}</td>
|
||||
<td>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: (stop.count / maxStopCount * 100) + '%' }"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RENDIMIENTO SHUTTLES -->
|
||||
<section class="analysis-section">
|
||||
<div class="section-header">
|
||||
<span class="material-icons">trending_up</span>
|
||||
<h2>Tasa de Reservación (Shuttles)</h2>
|
||||
</div>
|
||||
<div class="data-table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ruta</th>
|
||||
<th>Conversión</th>
|
||||
<th>Ratio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(data, id) in stats.shuttles" :key="id">
|
||||
<td class="id-cell">{{ id }}</td>
|
||||
<td>{{ calculateConversion(data.views, data.contacts) }}%</td>
|
||||
<td>
|
||||
<div class="mini-bar"><div class="fill" :style="{ width: calculateConversion(data.views, data.contacts) + '%' }"></div></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<aside class="side-info">
|
||||
<div class="info-box accent">
|
||||
<span class="material-icons">local_shipping</span>
|
||||
<h4>Optimización de Logística</h4>
|
||||
<p>Identifique paradas saturadas para coordinar con los conductores. Las rutas con conversión mayor al 15% son candidatas para ser rutas 'Express'.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 3: COMMERCE -->
|
||||
<div v-if="activeTab === 'commerce'" class="tab-content animate-fade">
|
||||
<div class="dashboard-layout">
|
||||
<div class="main-content">
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon promo"><span class="material-icons">confirmation_number</span></div>
|
||||
<div class="kpi-data">
|
||||
<span class="kpi-value">{{ stats.summary?.total_promo_clicks || 0 }}</span>
|
||||
<span class="kpi-label">Cupones Activados</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon biz"><span class="material-icons">storefront</span></div>
|
||||
<div class="kpi-data">
|
||||
<span class="kpi-value">{{ stats.summary?.total_biz_views || 0 }}</span>
|
||||
<span class="kpi-label">Visitas a Negocios</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="analysis-section">
|
||||
<div class="section-header">
|
||||
<span class="material-icons">ads_click</span>
|
||||
<h2>Impacto de Aliados Comerciales</h2>
|
||||
</div>
|
||||
<div class="data-table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Negocio</th>
|
||||
<th>Visitas</th>
|
||||
<th>Cupones</th>
|
||||
<th>Salud</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(data, id) in stats.businesses" :key="id">
|
||||
<td class="id-cell">{{ id }}</td>
|
||||
<td>{{ data.views }}</td>
|
||||
<td>{{ data.promos }}</td>
|
||||
<td>
|
||||
<span class="status-pill" :class="getHealthClass(calculateConversion(data.views, data.promos))">
|
||||
{{ getHealthLabel(calculateConversion(data.views, data.promos)) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<aside class="side-info">
|
||||
<div class="info-box">
|
||||
<span class="material-icons">shopping_bag</span>
|
||||
<h4>Retorno Comercial</h4>
|
||||
<p>Analice qué negocios están monetizando mejor el tráfico de SIBU. Use estos datos para ofrecer espacios publicitarios premium a los negocios con salud 'Baja'.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { apiClient } from '@/services/apiClient';
|
||||
import { Bar, Line } from 'vue-chartjs';
|
||||
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, PointElement, LineElement } from 'chart.js';
|
||||
|
||||
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, PointElement, LineElement);
|
||||
|
||||
const loading = ref(true);
|
||||
const activeTab = ref('overview');
|
||||
const stats = ref<any>({
|
||||
shuttles: {},
|
||||
businesses: {},
|
||||
top_stops: [],
|
||||
users: { registered_active: 0, patterns: { registered: {}, guests: {} } },
|
||||
summary: { total_shuttle_contacts: 0, total_promo_clicks: 0, total_biz_views: 0 }
|
||||
});
|
||||
|
||||
const totalInteractionCount = computed(() => {
|
||||
const s = stats.value.summary;
|
||||
return (s.total_shuttle_contacts || 0) + (s.total_promo_clicks || 0) + (s.total_biz_views || 0);
|
||||
});
|
||||
|
||||
const maxStopCount = computed(() => {
|
||||
if (!stats.value.top_stops.length) return 1;
|
||||
return Math.max(...stats.value.top_stops.map((s: any) => s.count));
|
||||
});
|
||||
|
||||
import jsPDF from 'jspdf';
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
const generateReport = async () => {
|
||||
const loadingNotify = ref(true); // Podríamos añadir un pequeño indicator de "Generando..."
|
||||
const date = new Date().toLocaleDateString('es-ES', { month: 'long', year: 'numeric' });
|
||||
const doc = new jsPDF('p', 'mm', 'a4');
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
|
||||
// 1. ENCABEZADO STARK STYLE
|
||||
doc.setFillColor(30, 41, 59); // Color oscuro SIBU
|
||||
doc.rect(0, 0, pageWidth, 40, 'F');
|
||||
|
||||
doc.setTextColor(254, 231, 21); // Amarillo Activo
|
||||
doc.setFontSize(22);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('SIBU COMMAND CENTER', 15, 20);
|
||||
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`INFORME DE INTELIGENCIA ESTRATÉGICA - ${date.toUpperCase()}`, 15, 30);
|
||||
doc.text(`Generado el: ${new Date().toLocaleString()}`, pageWidth - 15, 30, { align: 'right' });
|
||||
|
||||
let cursorY = 55;
|
||||
|
||||
// 2. RESUMEN EJECUTIVO
|
||||
doc.setTextColor(30, 41, 59);
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('1. RESUMEN DEL ECOSISTEMA', 15, cursorY);
|
||||
cursorY += 10;
|
||||
|
||||
doc.setFontSize(11);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
const summaryText = `Durante el periodo actual, se han detectado ${stats.value.users?.registered_active || 0} usuarios registrados activos. Las interacciones totales en la red ascienden a ${totalInteractionCount.value}, demostrando un flujo de actividad estable.`;
|
||||
const splitSummary = doc.splitTextToSize(summaryText, pageWidth - 30);
|
||||
doc.text(splitSummary, 15, cursorY);
|
||||
cursorY += splitSummary.length * 7;
|
||||
|
||||
// 3. CAPTURA DE GRÁFICOS (Solo si están visibles o los forzamos)
|
||||
// Nota: html2canvas captura el DOM. Intentaremos capturar los contenedores de las gráficas
|
||||
const charts = document.querySelectorAll('.chart-container');
|
||||
if (charts.length > 0) {
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('2. ANÁLISIS VISUAL DE TENDENCIAS', 15, cursorY);
|
||||
cursorY += 10;
|
||||
|
||||
for (const chart of Array.from(charts).slice(0, 2)) {
|
||||
if (cursorY > 220) { doc.addPage(); cursorY = 20; }
|
||||
const canvas = await html2canvas(chart as HTMLElement, { backgroundColor: '#1e293b' });
|
||||
const imgData = canvas.toDataURL('image/png');
|
||||
doc.addImage(imgData, 'PNG', 15, cursorY, pageWidth - 30, 60);
|
||||
cursorY += 70;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. TABLAS DE DATOS (TRANSPORTE & CASETAS)
|
||||
if (cursorY > 200) { doc.addPage(); cursorY = 20; }
|
||||
doc.setTextColor(30, 41, 59);
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('3. LOGÍSTICA Y MOVILIDAD TÁCTICA', 15, cursorY);
|
||||
cursorY += 10;
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.text('Top 5 Casetas con más concurrencia:', 15, cursorY);
|
||||
cursorY += 7;
|
||||
|
||||
stats.value.top_stops.slice(0, 5).forEach((stop: any) => {
|
||||
doc.text(`- Caseta #${stop.id}: ${stop.count} peticiones directas detactadas.`, 20, cursorY);
|
||||
cursorY += 6;
|
||||
});
|
||||
|
||||
// 5. INTELIGENCIA COMERCIAL
|
||||
cursorY += 10;
|
||||
if (cursorY > 240) { doc.addPage(); cursorY = 20; }
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('4. IMPACTO COMERCIAL (ALIADOS)', 15, cursorY);
|
||||
cursorY += 10;
|
||||
doc.setFontSize(11);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`Promociones activadas: ${stats.value.summary?.total_promo_clicks || 0} veces.`, 15, cursorY);
|
||||
cursorY += 6;
|
||||
doc.text(`Interés en perfiles de negocio: ${stats.value.summary?.total_biz_views || 0} visitas registradas.`, 15, cursorY);
|
||||
|
||||
// FOOTER
|
||||
const totalPages = doc.internal.pages.length - 1;
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
doc.setPage(i);
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(150);
|
||||
doc.text(`SIBU Command Center - Página ${i} de ${totalPages} - Confidencial Admin`, pageWidth / 2, 285, { align: 'center' });
|
||||
}
|
||||
|
||||
doc.save(`Informe_Estrategico_SIBU_${date.replace(/ /g, '_')}.pdf`);
|
||||
};
|
||||
|
||||
// CHARTS CONFIGURATION (MISMOS DATOS QUE ANTES)
|
||||
const usageChartData = computed(() => {
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
return {
|
||||
labels: hours.map(h => `${h}:00`),
|
||||
datasets: [
|
||||
{ label: 'Registrados', data: hours.map(h => stats.value.users.patterns.registered[h] || 0), borderColor: '#fee715', backgroundColor: 'rgba(254, 231, 21, 0.2)', tension: 0.4, fill: true },
|
||||
{ label: 'Invitados', data: hours.map(h => stats.value.users.patterns.guests[h] || 0), borderColor: '#64748b', backgroundColor: 'rgba(100, 116, 139, 0.1)', tension: 0.4, fill: true }
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
const routesChartData = computed(() => {
|
||||
const routes = stats.value.shuttles || {};
|
||||
const labels = Object.keys(routes);
|
||||
return {
|
||||
labels: labels.slice(0, 8),
|
||||
datasets: [{ label: 'Consultas', data: labels.slice(0, 8).map(l => routes[l].views), backgroundColor: '#fee715', borderRadius: 10 }]
|
||||
};
|
||||
});
|
||||
|
||||
const usageChartOptions = { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#cbd5e1' } } }, scales: { y: { grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#64748b' } }, x: { ticks: { color: '#64748b' } } } };
|
||||
const routesChartOptions = { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#64748b' } }, x: { ticks: { color: '#64748b' } } } };
|
||||
|
||||
const calculateConversion = (views: number, actions: number) => (views > 0 ? ((actions / views) * 100).toFixed(1) : 0);
|
||||
const getHealthClass = (rate: any) => (parseFloat(rate) > 20 ? 'excellent' : parseFloat(rate) > 10 ? 'good' : 'low');
|
||||
const getHealthLabel = (rate: any) => (parseFloat(rate) > 20 ? 'Alta' : parseFloat(rate) > 10 ? 'Media' : 'Baja');
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/analytics/strategic');
|
||||
stats.value = response.data;
|
||||
} catch (error) { console.error(error); } finally { loading.value = false; }
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.strategic-analytics { padding: 40px 24px 120px; max-width: 1350px; margin: 0 auto; color: var(--text-primary); }
|
||||
.header-section { margin-bottom: 30px; }
|
||||
.top-row { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
|
||||
|
||||
.download-btn {
|
||||
background: rgba(254, 231, 21, 0.1);
|
||||
border: 1px solid var(--active-color);
|
||||
color: var(--active-color);
|
||||
padding: 8px 16px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background: var(--active-color);
|
||||
color: #101820;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(254, 231, 21, 0.3);
|
||||
}
|
||||
h1 { font-size: 2.2rem; font-weight: 900; margin: 0; }
|
||||
.subtitle { color: var(--text-secondary); margin-top: 6px; }
|
||||
|
||||
/* TABS */
|
||||
.tabs-control { display: flex; gap: 12px; margin-bottom: 40px; padding-bottom: 20px; border-bottom: 1px solid var(--border-color); }
|
||||
.tab-btn { background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-secondary); padding: 12px 24px; border-radius: 16px; display: flex; align-items: center; gap: 10px; cursor: pointer; transition: all 0.3s; font-weight: 700; }
|
||||
.tab-btn.active { background: var(--active-color); color: #101820; border-color: var(--active-color); }
|
||||
.tab-btn:hover:not(.active) { border-color: var(--active-color); color: var(--active-color); }
|
||||
|
||||
/* CONTENT */
|
||||
.dashboard-layout { display: grid; grid-template-columns: 1fr 340px; gap: 40px; }
|
||||
.analysis-section { margin-bottom: 60px; }
|
||||
.section-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
||||
.section-header h2 { font-size: 1rem; font-weight: 800; text-transform: uppercase; color: var(--text-secondary); }
|
||||
|
||||
.side-info { display: flex; flex-direction: column; gap: 16px; }
|
||||
.info-box { background: var(--bg-secondary); padding: 24px; border-radius: 24px; border: 1px solid var(--border-color); }
|
||||
.info-box .material-icons { color: #fee715; margin-bottom: 12px; }
|
||||
.info-box h4 { margin: 0 0 8px; font-weight: 800; color: #fee715; }
|
||||
.info-box p { font-size: 0.85rem; line-height: 1.6; color: var(--text-secondary); margin: 0; }
|
||||
|
||||
/* KPI */
|
||||
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 24px; margin-bottom: 40px; }
|
||||
.kpi-card { background: var(--card-bg); padding: 24px; border-radius: 24px; border: 1px solid var(--border-color); display: flex; align-items: center; gap: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); }
|
||||
.kpi-icon { width: 50px; height: 50px; border-radius: 14px; display: flex; align-items: center; justify-content: center; background: rgba(254, 231, 21, 0.1); color: #fee715; }
|
||||
.kpi-value { display: block; font-size: 2rem; font-weight: 900; }
|
||||
.kpi-label { font-size: 0.75rem; color: var(--text-secondary); font-weight: 600; text-transform: uppercase; }
|
||||
|
||||
/* TABLES & CHARTS */
|
||||
.chart-container { height: 320px; background: rgba(0,0,0,0.2); border-radius: 24px; padding: 24px; border: 1px solid var(--border-color); }
|
||||
.chart-container.large { height: 400px; }
|
||||
.data-table-wrapper { background: var(--card-bg); border-radius: 24px; border: 1px solid var(--border-color); overflow: hidden; }
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th { padding: 16px; font-size: 0.7rem; color: var(--text-secondary); text-transform: uppercase; text-align: left; background: rgba(255,255,255,0.02); }
|
||||
.data-table td { padding: 18px 16px; border-bottom: 1px solid var(--border-color); }
|
||||
|
||||
.id-cell { font-family: monospace; color: #fee715; font-weight: 700; }
|
||||
.progress-bar { width: 100%; height: 6px; background: rgba(255,255,255,0.05); border-radius: 10px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: #fee715; }
|
||||
.mini-bar { width: 80px; height: 5px; background: rgba(255,255,255,0.05); border-radius: 10px; overflow: hidden; }
|
||||
.mini-bar .fill { height: 100%; background: #fee715; }
|
||||
|
||||
.status-pill { padding: 4px 12px; border-radius: 100px; font-size: 0.7rem; font-weight: 800; }
|
||||
.status-pill.excellent { background: rgba(16, 185, 129, 0.1); color: #10b981; }
|
||||
.status-pill.good { background: rgba(59, 130, 246, 0.1); color: #3b82f6; }
|
||||
.status-pill.low { background: rgba(244, 63, 94, 0.1); color: #f43f5e; }
|
||||
|
||||
/* ANIMATIONS */
|
||||
.animate-fade { animation: fadeIn 0.4s ease-out; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.spin { animation: spin 2s linear infinite; }
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 1100px) { .dashboard-layout { grid-template-columns: 1fr; } .side-info { order: 2; } }
|
||||
</style>
|
||||
959
frontend/src/views/TaxiView.vue
Normal file
959
frontend/src/views/TaxiView.vue
Normal file
@ -0,0 +1,959 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTaxiStore } from '@/stores/taxi'
|
||||
import { useShuttleStore } from '@/stores/shuttle'
|
||||
import { analyticsService } from '@/services/analyticsService'
|
||||
import { API_URL } from '@/services/apiClient'
|
||||
import type { Taxi, Shuttle } from '@/types'
|
||||
import FavoriteButton from '@/components/FavoriteButton.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const taxiStore = useTaxiStore()
|
||||
const shuttleStore = useShuttleStore()
|
||||
|
||||
const currentTab = ref<'local' | 'intercity'>('local')
|
||||
const selectedZone = ref('all')
|
||||
const selectedShift = ref('all')
|
||||
const onlyEnglish = ref(false)
|
||||
|
||||
const corregimientos = ['all', 'Boquete', 'David - Boquete', 'Boquete - David', 'Aeropuerto - Boquete']
|
||||
const shifts = ['all', 'dia', 'tarde', 'noche']
|
||||
|
||||
// Shuttle Filters
|
||||
const shuttleRouteFilter = ref('all')
|
||||
const shuttleTypeFilter = ref('all')
|
||||
const expandedShuttleId = ref<string | null>(null)
|
||||
|
||||
const shuttleRoutes = computed(() => {
|
||||
const routes = shuttleStore.shuttles.map(s => `${s.origin} - ${s.destination}`)
|
||||
return [...new Set(routes)].sort()
|
||||
})
|
||||
|
||||
const filteredShuttles = computed(() => {
|
||||
return shuttleStore.shuttles.filter(shuttle => {
|
||||
const routeName = `${shuttle.origin} - ${shuttle.destination}`
|
||||
const matchesRoute = shuttleRouteFilter.value === 'all' || routeName === shuttleRouteFilter.value
|
||||
const matchesType = shuttleTypeFilter.value === 'all' || shuttle.trip_type === shuttleTypeFilter.value
|
||||
return matchesRoute && matchesType
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
analyticsService.logEvent({ event_name: 'screen_view', screen_name: 'TransportHub' })
|
||||
await Promise.all([
|
||||
taxiStore.loadTaxis(),
|
||||
shuttleStore.loadShuttles()
|
||||
])
|
||||
})
|
||||
|
||||
const filteredTaxis = computed(() => {
|
||||
return taxiStore.taxis.filter(taxi => {
|
||||
const matchesZone = selectedZone.value === 'all' || taxi.corregimiento === selectedZone.value
|
||||
const matchesShift = selectedShift.value === 'all' || taxi.shift === selectedShift.value
|
||||
const matchesEnglish = !onlyEnglish.value || taxi.english_speaking
|
||||
return matchesZone && matchesShift && matchesEnglish
|
||||
})
|
||||
})
|
||||
|
||||
function getImageUrl(path?: string) {
|
||||
if (!path) return `https://ui-avatars.com/api/?name=Taxi&background=fee715&color=101820`
|
||||
if (path.startsWith('http')) return path
|
||||
return `${API_URL}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
}
|
||||
|
||||
const handleCall = (taxi: Taxi) => {
|
||||
analyticsService.logEvent({
|
||||
event_name: 'taxi_click',
|
||||
item_id: taxi.owner_name,
|
||||
properties: {
|
||||
action: 'call',
|
||||
taxi_id: taxi.id,
|
||||
plate: taxi.license_plate
|
||||
}
|
||||
})
|
||||
window.location.href = `tel:${taxi.phone_number}`
|
||||
}
|
||||
|
||||
const handleReserve = (shuttle: Shuttle) => {
|
||||
analyticsService.logEvent({
|
||||
event_name: 'shuttle_contact',
|
||||
item_id: shuttle.id,
|
||||
properties: { action: 'whatsapp', route: shuttle.route_name }
|
||||
})
|
||||
const message = encodeURIComponent(`Hola SIBU, me gustaría reservar un cupo para la ruta: ${shuttle.route_name}.`)
|
||||
window.open(`https://wa.me/${shuttle.contact_whatsapp.replace(/\+/g, '')}?text=${message}`, '_blank')
|
||||
}
|
||||
|
||||
const handleCallShuttle = (shuttle: Shuttle) => {
|
||||
analyticsService.logEvent({
|
||||
event_name: 'shuttle_contact',
|
||||
item_id: shuttle.id,
|
||||
properties: { action: 'call', route: shuttle.route_name }
|
||||
})
|
||||
}
|
||||
|
||||
function getShiftLabel(shift: string) {
|
||||
if (shift === 'dia') return t('taxi.dayShift')
|
||||
if (shift === 'tarde') return t('taxi.afternoonShift')
|
||||
if (shift === 'noche') return t('taxi.nightShift')
|
||||
return shift
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="taxi-view">
|
||||
<header class="header-main">
|
||||
<h1 class="brand-title">{{ t('taxi.title') }}</h1>
|
||||
<!-- Tab Switcher Premium -->
|
||||
<div class="hub-tabs">
|
||||
<div class="tabs-background">
|
||||
<button
|
||||
class="hub-tab"
|
||||
:class="{ active: currentTab === 'local' }"
|
||||
@click="currentTab = 'local'"
|
||||
>
|
||||
<span class="material-icons">local_taxi</span>
|
||||
{{ t('taxi.tabLocal') }}
|
||||
</button>
|
||||
<button
|
||||
class="hub-tab"
|
||||
:class="{ active: currentTab === 'intercity' }"
|
||||
@click="currentTab = 'intercity'"
|
||||
>
|
||||
<span class="material-icons">directions_bus</span>
|
||||
{{ t('taxi.tabIntercity') }}
|
||||
</button>
|
||||
<div class="tab-slider" :style="{ left: currentTab === 'local' ? '4px' : 'calc(50% + 2px)' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- TAB 1: LOCAL TAXIS -->
|
||||
<template v-if="currentTab === 'local'">
|
||||
<div class="filters-container">
|
||||
<div class="filter-card">
|
||||
<div class="selectors-side">
|
||||
<div class="select-group">
|
||||
<span class="material-icons">location_on</span>
|
||||
<select v-model="selectedZone">
|
||||
<option value="all">{{ t('taxi.allZones') }}</option>
|
||||
<option v-for="zone in corregimientos.filter(z => z !== 'all')" :key="zone" :value="zone">{{ zone }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="select-group">
|
||||
<span class="material-icons">schedule</span>
|
||||
<select v-model="selectedShift">
|
||||
<option value="all">{{ t('taxi.shift') }}</option>
|
||||
<option v-for="s in shifts.filter(x => x !== 'all')" :key="s" :value="s">{{ getShiftLabel(s) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lang-toggle-side">
|
||||
<span class="lang-text">{{ t('taxi.englishLabel') }}</span>
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" v-model="onlyEnglish">
|
||||
<span class="checkmark"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="taxiStore.isLoading" class="state-container">
|
||||
<span class="material-icons spin">refresh</span>
|
||||
<p>{{ t('taxi.loadingTaxis') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="taxiStore.error" class="state-container">
|
||||
<span class="material-icons">error_outline</span>
|
||||
<p>{{ taxiStore.error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="taxis-grid">
|
||||
<div v-for="taxi in filteredTaxis" :key="taxi.id" class="taxi-card-new">
|
||||
<div class="card-top">
|
||||
<div class="driver-avatar">
|
||||
<img :src="getImageUrl(taxi.image_url)" alt="Driver">
|
||||
</div>
|
||||
<div class="driver-info">
|
||||
<h3>{{ taxi.owner_name }}</h3>
|
||||
<div class="rating-stars">
|
||||
<span v-for="i in 5" :key="i" class="material-icons">
|
||||
{{ i <= (taxi.rating || 5) ? 'star' : 'star_border' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fav-icon-wrapper">
|
||||
<FavoriteButton
|
||||
item-type="taxi"
|
||||
:item-id="taxi.id"
|
||||
:item-name="taxi.owner_name"
|
||||
:item-image="taxi.image_url || undefined"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-mid">
|
||||
<div class="contact-info">
|
||||
<span class="material-icons ph-icon">phone</span>
|
||||
<span class="phone-num"> {{ taxi.phone_number }} </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-bottom">
|
||||
<button class="call-btn-main" @click="handleCall(taxi)">
|
||||
<span class="material-icons">phone_in_talk</span>
|
||||
{{ t('taxi.callNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredTaxis.length === 0" class="empty-state">
|
||||
<span class="material-icons">no_accounts</span>
|
||||
<p>{{ t('taxi.noTaxisAvailable') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- TAB 2: INTERCITY SHUTTLES -->
|
||||
<template v-else>
|
||||
<div class="filters-container">
|
||||
<div class="filter-card">
|
||||
<div class="selectors-side">
|
||||
<div class="select-group">
|
||||
<span class="material-icons">route</span>
|
||||
<select v-model="shuttleRouteFilter">
|
||||
<option value="all">{{ t('shuttle.allRoutes') }}</option>
|
||||
<option v-for="route in shuttleRoutes" :key="route" :value="route">{{ route }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="select-group">
|
||||
<span class="material-icons">sync_alt</span>
|
||||
<select v-model="shuttleTypeFilter">
|
||||
<option value="all">{{ t('shuttle.tripType') }}</option>
|
||||
<option value="one_way">{{ t('shuttle.oneWay') }}</option>
|
||||
<option value="round_trip">{{ t('shuttle.roundTrip') }}</option>
|
||||
<option value="both">{{ t('shuttle.both') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="shuttleStore.isLoading" class="state-container">
|
||||
<span class="material-icons spin">refresh</span>
|
||||
<p>{{ t('taxi.loadingTaxis') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="shuttleStore.error" class="state-container">
|
||||
<span class="material-icons">error_outline</span>
|
||||
<p>{{ shuttleStore.error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="shuttles-grid">
|
||||
<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'})` }"
|
||||
@click="() => {
|
||||
expandedShuttleId = expandedShuttleId === shuttle.id ? null : shuttle.id;
|
||||
if (expandedShuttleId === shuttle.id) {
|
||||
analyticsService.logEvent({ event_name: 'shuttle_view', item_id: shuttle.id });
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div class="shuttle-main-info">
|
||||
<div class="shuttle-header-mini">
|
||||
<div class="company-badge" v-if="shuttle.company_name">
|
||||
<span class="material-icons">business</span>
|
||||
{{ shuttle.company_name }}
|
||||
</div>
|
||||
<div class="price-pill">
|
||||
<span class="currency">$</span>
|
||||
<span class="amount">{{ shuttle.price_per_person }}</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="route-text">{{ shuttle.destination }}</span>
|
||||
</div>
|
||||
|
||||
<div class="shuttle-tags">
|
||||
<div class="vehicle-tag-mini">
|
||||
<span class="material-icons">directions_bus</span>
|
||||
{{ shuttle.vehicle_type }}
|
||||
</div>
|
||||
<div class="expand-indicator">
|
||||
<span class="material-icons">{{ expandedShuttleId === shuttle.id ? 'expand_less' : 'expand_more' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EXPANDABLE CONTENT -->
|
||||
<div class="shuttle-details" v-if="expandedShuttleId === shuttle.id">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Transport Hub Tabs */
|
||||
.hub-tabs {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tabs-background {
|
||||
background: var(--bg-secondary);
|
||||
padding: 4px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
border: 1px solid var(--border-color);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.hub-tab {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
transition: color 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hub-tab.active {
|
||||
color: #101820 !important;
|
||||
}
|
||||
|
||||
.hub-tab .material-icons {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.tab-slider {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: calc(50% - 6px);
|
||||
background: #FEE715;
|
||||
border-radius: 12px;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 1;
|
||||
box-shadow: 0 4px 15px rgba(254, 231, 21, 0.4);
|
||||
}
|
||||
|
||||
/* Shuttles Grid & Compact Cards */
|
||||
.shuttles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.shuttle-card.expanded {
|
||||
border-color: var(--active-color);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.shuttle-main-info {
|
||||
padding: 0; /* Ya manejado por el contenedor superior */
|
||||
}
|
||||
|
||||
.shuttle-header-mini {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.company-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(254, 231, 21, 0.1);
|
||||
color: var(--active-color);
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
border: 1px solid rgba(254, 231, 21, 0.2);
|
||||
}
|
||||
|
||||
.price-pill {
|
||||
background: var(--active-color);
|
||||
color: #101820;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
font-weight: 900;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.shuttle-route-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.shuttle-route-compact .material-icons {
|
||||
color: var(--active-color);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.shuttle-tags {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vehicle-tag-mini {
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.expand-indicator {
|
||||
color: var(--active-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Expanded content */
|
||||
.shuttle-details {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.shuttle-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-row .material-icons {
|
||||
color: var(--active-color);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.info-row .label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.info-row .value {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
margin: 2px 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.price-main {
|
||||
color: var(--active-color);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.price-main .currency {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.price-main .amount {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.price-main .suffix {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.price-sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contact-hub {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto; /* Empuja al máximo a la derecha */
|
||||
}
|
||||
|
||||
.mini-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.lang-badge .material-icons { font-size: 10px; }
|
||||
|
||||
/* Original Styles */
|
||||
.taxi-view {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
padding: 0 0 150px; /* Aumentado para evitar solapamiento con BottomNav */
|
||||
}
|
||||
|
||||
.taxi-view {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
padding: 0 0 150px;
|
||||
}
|
||||
|
||||
/* Tarjetas claras y elegantes */
|
||||
.filter-card,
|
||||
.taxi-card-new {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* SHUTTLE CARD PREMIUM CON FONDO */
|
||||
.shuttle-card {
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shuttle-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.3) 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.shuttle-main-info, .shuttle-details {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
color: var(--header-text);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filters-container {
|
||||
padding: 0 16px 24px;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 20px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
|
||||
.selectors-side {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.select-group {
|
||||
background: var(--bg-secondary);
|
||||
border: 1.5px solid #fee715;
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.select-group .material-icons {
|
||||
color: #fee715;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.select-group select {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
width: 100%;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.select-group select option {
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.lang-toggle-side {
|
||||
padding-left: 20px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lang-text {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Custom Checkbox */
|
||||
.checkbox-container {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-container input {
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
background-color: transparent;
|
||||
border: 2px solid #fee715;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.checkbox-container input:checked ~ .checkmark {
|
||||
background-color: #fee715;
|
||||
}
|
||||
|
||||
.checkmark:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkbox-container input:checked ~ .checkmark:after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.checkbox-container .checkmark:after {
|
||||
left: 9px;
|
||||
top: 5px;
|
||||
width: 6px;
|
||||
height: 12px;
|
||||
border: solid #101820;
|
||||
border-width: 0 3px 3px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* Grid & Cards */
|
||||
.taxis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 24px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.taxi-card-new {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-radius: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.taxi-card-new:hover {
|
||||
transform: translateY(-8px);
|
||||
border-color: var(--active-color);
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.driver-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.driver-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.driver-info h3 {
|
||||
margin: 0 0 2px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.rating-stars {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rating-stars .material-icons {
|
||||
color: var(--active-color);
|
||||
font-size: 18px;
|
||||
filter: drop-shadow(0 0 5px rgba(254, 231, 21, 0.5));
|
||||
}
|
||||
|
||||
.fav-icon-wrapper {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.card-mid {
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ph-icon {
|
||||
color: var(--active-color);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.phone-num {
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.call-btn-main {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
|
||||
color: #101820;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 10px 20px rgba(254, 231, 21, 0.15);
|
||||
}
|
||||
|
||||
.call-btn-main:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 15px 30px rgba(254, 231, 21, 0.25);
|
||||
}
|
||||
|
||||
.state-container, .empty-state {
|
||||
padding: 100px 24px;
|
||||
text-align: center;
|
||||
background: var(--header-bg);
|
||||
border-radius: 32px;
|
||||
border: 2px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.state-container .material-icons, .empty-state .material-icons {
|
||||
font-size: 64px;
|
||||
margin-bottom: 24px;
|
||||
color: var(--active-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.taxis-grid, .shuttles-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user