Initial commit: SIBU 2.0 MISSION

This commit is contained in:
2026-02-21 09:53:31 -05:00
commit 0c7aa53c8b
400 changed files with 67708 additions and 0 deletions

View File

@ -0,0 +1,253 @@
<template>
<div class="admin-bus-stops">
<div class="header">
<button class="back-link" @click="$router.push('/admin')">&larr; 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View File

@ -0,0 +1,592 @@
<template>
<div class="admin-routes">
<div class="header">
<button class="back-link" @click="router.push('/admin')">&larr; 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 }} &rarr; {{ 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>

View 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 }} &rarr; {{ 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>

View 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>

View 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">&times;</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

View 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>